Motivation

가끔씩 현업에서 Unreal Engine 이나, 대학원때 공부했었던 Parallel Computing System 을 공부했었을때, 뭔가 부족한 점도 많았고, Wording 이 친숙하지 않아서 인지 아쉬움이 많았다. 그래서 GPU 를 다뤄보는 내용으로 블로그를 쓰려고 한다.


Index

CPU / GPU 설계 철학

  • 반응 시간 (Latency) 우선
  • 처리량 (throughput) 우선

대규모 병렬 컴퓨팅(massively parallel computing)

  • MPC = massivlely parallel computing

Introduction

알다 싶이, CPU (Central Processing Unit) 처음에는 폰 노이만 구조를 가지고 있는 Single Core CPU 로 이루어져있었다. 하나의 긴 Data Bus 아래에 Memory, Processor( CU + ALU ), and I/O Device 로 되어있었다; Control Unit (ALU 의 제어 담당) and Arithemetic-logic unit. 즉 이게 하나였던 Architecture 이였다. 그리고 Dual Core 로 CU / ALU 가 두개가 생겨났었다. 이게 점점 더 늘어나면서 32+ Core 들이 만들어졌다. 하지만 GPU 는 many core 이라고 해서, 애초에 1024~8192+ core 로 GPU 를 만들게 되었다.

고성능 멀티 코어 CPU 같은 경우의 철학 같은 경우, 반응 시간 (Latency) 단축 시키는 철학을 가지고 만들었으며, 그 이유로는 순차 처리 sequential processing 에 적합 하게끔, 그리고 기존 고성능 코어를 추가하는식으로 만들어져있다.

하지만 대규모로 만들어지는 Many-Core 을 철학으로 만든 GPU 같은 경우, 처리량 Thoughput 확대를 위주로 만드는 철학을 가지고 있었다. 즉 Parallel processing 에 집중을 했었고, 성능과는 무관하게, 코어 숫자 증가에 집중을 했다. 즉 Control Unit 와 Cache 는 작지만, 1 개의 Control Unit 자체가 여러개의 ALU 를 관리하고 동시에 실행이 되니 Thread Pool 에 효과적으로 집중을 할수 있고, 이 결과로 단위시간당 처리량 대폭 확대를 할수 있게 됬다.

Models

여기서 Model 은 결국 Device + Programming Language + Compiler + Library 를 뜻한다. 예를 들어서 종류는 아래와 같다.

  1. OpenMP : Open Multi-Processing
    1. 멀티 코어 CPU 용
    2. GPU 로 확장중
  2. CUDA: Compute Unified Device Architecture
    1. NVIDIA GPU 전용 으로 사용되고, 현재는 Cloud Computing 을 사용 가능
  3. OpenCL: Open Computing Language
    1. CPU / GPU / FPGA 모두 제공
    2. Apple, Intel, AMD/ATI, NVIDIA …
    3. 범용성을 추구 하고, 좀 더 복잡한 모델, 교육용으로는 레벨이 높다.

CUDA (Compute Unified Device Architecture) Programming

CUDA 라고 하면 주로 Computer Vision 이나 Computer Graphics 쪽에서 많이 이야기 하게 된다. 여기서는 Computer Graphics 를 조금 집중적으로 이야기를 하려고 한다.

Computer Graphics 은 결국 현실과 똑같은 걸 Computer 를 사용해서 표현 해야하므로, Grpahics Model 들이 있고, 이런 Graphics Model 들은 Physics 나 Optics 에 대한 법칙에 기반에서 만들어져야 했다. 그리고 이러한 Model 들은 현실 세계와 마찬 가지로 부드럽게 Motion 들이 생성이 되어야한다. 더 나아가서 더 빠르게 실시간에서 처리를 해야한다라는게 중요한 포인트의 학문이 바로 Computer Graphics 라고 말을 할 수 있다.

잠시 아래의 reference 된 그림들을 보자면, 아래의 그림들은 흔히 알고 있는 Graphics Pipeline 이다. 결국엔 Host 즉 CPU 에서 대용량으로 처리할 수 있는 데이터를 GPU 로 보낸 다음 저런 Pipeline 을 통해서, 마지막 Frame Buffer 에다가 넘겨서, 우리 화면에 게임과 같은 가상현실들을 볼수 있는거다.

두번째 그림에서는 결국엔 GPU 의 처리 성능 부분이 어디에서 주로 이루어졌냐 라고 물어봤을때는 Vertex ProcessingFrament Processing 즉 수많은 Vertex 로 도형을 만들고, 그리고 이 도형들을 색을 결정하는데에 처리량이 많이 들어가므로, 특히 GPU 의 처리 기능이 저런 Processing 쪽에 들어간다.

CUDA 프로그래밍 (0) - C/C++/GPU 병렬 컴퓨팅(1) CUDA 프로그래밍 (0) - C/C++/GPU 병렬 컴퓨팅(2)

이런 처리 기능 성능 부분들을 어떤 방식으로 처리를 했냐면 바로 GPGPU 의 도임을 말을 할 수 있다. 바로 GPGPU(general purpose graphics processing unit) 이라고 말을 할수 있고, GPU 의 하드웨어를 좋은 계산기로 사용할 수 있는 Techniqe 이라고 볼수 있다.

조금더 구체적으로 CPU 에서 GPU 가 이해 할 수 있는 입력으로 변환을 하고, GPU 가 처리한 이후 어떠한 Image Data 로 다시 CPU 가 받을수 있게 하거나 GPU 의 출력해서 모니터로 불수 있게끔하게 된다. 이러한 방식이 바로 GPGPU (General Purpose GPU Programming) 의 방식이다. CUDA 프로그래밍 (0) - C/C++/GPU 병렬 컴퓨팅(3)

CUDA Architecture

  • Compute Unified Device Architecture
  • GPU 에서 대규모 쓰레드 Thread 를 실행
  • GPU = 대규모 병렬 처리 코프로세서 Massively Data Parallel Co-Processor
  • Model = Device / Computer Architecture + Programming Language + Compiler + Much More
  • CPU 를 범용으로 사용하는 ToolKit 으로 구성 (C / C++)
    • CUDA Driver -> GPU 로 구동
    • CUDA Library -> API 함수들
    • GPU 기능을 직접 제어 가능 -> 최고 효율 획득 GPU Computing Applications

Lecture Reveiw

Develop Environment

  • CUDA SDK+ C++ compiler
  • NVIDA Graphics Hardware
  • C++11

Resource

  • https://www.inflearn.com/course/cuda-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%86%8C%EA%B0%9C/dashboard
  • https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html

DirectX Initialization

DirectX12 Background

일단 장치 초기화 하기 이전에, Hardware 를 알아보자. 컴퓨터를 뜯어봤으면 알수 있는건 바로 CPU 와 GPU 를 한번쯤은 봤을것이다. 아래의 구조를 한번 보자.

CPU 는 마치 고급인력 처럼 보인다. CPU 는 뭔가 큰작업을 빠르게 처리할수 있는 ALU 4 개가 있고, 반대로 GPU 같은 경우는 수많은 ALU 가 존재하는데 약간 인력집단처럼 정말 많다. 바로 여기서 힌트를 얻을수 있는게, 단순 또는 값싼 인력을이 많아서, 독립적인 일을 병렬로 처리할수 있게 보인다. 만약 독립적인일을 CPU 로 하기엔 뭔가 고급 인력들을 잡일로 시키는것 같다. 그래서 주로 CPU 같은 경우, 뭔가 데이터나 연산들이 서로가 서로 연관관계일때 주로 사용되는게 좋다. 다시 정리해서 말하면 CPU 는 고급인력이고, GPU 는 약간 외주(outsourcing) 을 하는 느낌이다. GPU 에게 외주를 맡길거면, 어떤 일을 해야 하는지, 사인을 어느 부분에서 해야하는지, 등 알려주는게 좋아서, 바로 Rendering Pipeline 이라는게 정의가 되었다.

Rendering Pipeline in DirectX

아래의 그림이 DirectX3D 11 의 Rendering Pipeline 이다. 각각이 뭐를 하는지 briefly 하게 넘어가보자.

Input Assembler 에서는 정점(Vertex) 의 정보들을 전달해주는거라고 생각을 하면 된다. 예를들어서 삼각형의 정점들이 어떻게 이루어졌는지. Vertex Shader 같은 경우, 아까의 정점들을 가지고 연산을 하는 단계이다. 예를들어서, Morphing, Translation, 등 정점의 이동등을 연산한다. Tessellation StageGeometry Shader Stage 같은경우 정점을 추가시켜주는 작업을 맡는다. 정점을 추가하는 이유는 예를 들어서 선명도를 좀더 표현하기 위해서 정점을 더 더해줘서 quality 를 올리는 등이 있다.

Rasterization Stage 는 정말 중요하다. 이 stage 가 오기전에는 정점을 가지고 놀았다면, 이제 각 정점에 Pixel 들을 입혀줘서, 삼각형이라고 한다면 어느정도 색깔과 모양을 그럴싸하게 표현해주는 stage 라고 생각하면 된다. 그런다음에 Pixel Shader 같은 경우 색상을 변경또는 조절을 담당한다. 그 다음에 최종 결과물을 이제 Output 으로 넘겨주는 형식이다.

이런 PipeLine 을 더 이해하고, 더 나아가서, GPU 에 다가 어떻게 일감을 던져주는지를 알려면 Device Initialization 이 필요하다. 이 아래에 설명이 있으니 한번 정리 해볼려고 한다.

Device Initialization (Component Descriptions)

일단 설계구조는 이렇다. Engine 안에 CommandQueue, DescriptorHeap, Device, SwapChain, RootSignautre 등이 들어간다. 그리고 Engine 내부에서는 초기화(Init) 이 들어가고, Rendering 을 할수 있는 Render 가 들어갈것이다.

일단 Device 를 한번 보자. Device 같은 경우, 인력 사무소라고 생각하면된다. 즉 GPU 와 소통할 공간이라고 생각하면 된다. 일단 Device 에서는 DirectX 를 지원하기위해서 ComPtr 타입을 사용한다. COM(Component Object Model) 인데, 이건 DX 의 programming 의 독립성과 하위 호환성을 위해서 만들어진 객체이며, COM 객체를 통해서, GPU 에 접근을 할수 있다. 즉 COM 을 사용하려면, ComPtr 을 접근을 통해서 COM 을 관리할수 있다.

Window 에서 device 를 사용하려면, Window API 를 사용해야하는데, 이때 CreateDXGIFactory 들지, D3D12CreateDevice 등 사용된다. 이때 사용되는 함수들은 Direct3D 와 같이 사용될수 있게끔 잘만들어줘야한다. 그래서 Device 객체안에서는 Init 이 존재한다.

그 다음 ComandQueue 를 알아보자. DX12 에 나온 친구인데, 뭔가 외주를 요청했을떄 따로 따로 일을 보내주면, 되게 비효율적이다. 그래서 한꺼번에 Command Queue 를 일들을 넣어줘서 실행시키는게 더 효율적이다. 그리고 Fence 라는 개념도 등장하는데, CPU 와 GPU 의 동기화를 맞춰줄떄 사용된다. 그냥 봐서는 일감만 던져주면 된다고 생각하지만, 만약에 그 일감이 돌아오기까지를 기다려서, 그다음 업무를 실행시킬떄는 Synchronize 를 해줘야하는데 그게 Fence 의 역활이다. 여기에서는 Init 과 sync 를 맞추기 위한 WaitSync 가 필요하다. 그리고 실질적으로 Rendering 이 시작되는 부분과 끝부분이 여기에서 필요하다. Rendering 을 한 결과물을 여기에서 RenderEnd 와 RenderBeging 을 통해서 RenderTarget 을 swapchain 에서 불러 들어와, 지금 현재 뿌려져있는걸 뿌리는걸 Handle 하고, RenderEnd 에서는 Backbuffer 와 현재 screen 에 뿌려진걸 바꿔주면서 다시 일감을 준다. 그리고 RenderEnd 에서 실제 수행한다.

그 다음 SwapChain 대해 알아보자. 일단은 직역을 하자면, 교환사슬이다. 이게 어떻게 사용되는지 생각을 해보면 일단 외주과정을 한번 살펴보면, 게임 세상에 어떤 상황을 묘사해서, 어떤 공식으로 계산을 할것인지를 외주 목록으로 던져줘서, GPU 가 열심히 계산을 한다음에 결과물을 받는다. 이 외주과정을 살펴보면, 한번만 한다고 하면 GPU 에서 처리한다음에 그대로 일을 보여주기만 하면 끝난다. 하지만 실제 게임은 그렇지 않다. 매 Frame 마다 게임은 실행되어야되며, 게임이 실행된다는건, 매 frame 마다 화면이 다르기때문에 GPU 의 계산한값과 CPU 로 부터 보여지는 화면등이 계속 바뀌어야한다는것이다. 그럴려면 SwapChain 이라는게 필요하다. 그리고 외주 결과물은 어떻게 받느냐라고 하면, 어떤 종이(컴퓨터에 관점에서는 buffer) 에 그려달라고 건내주면, 뭔가 특수 종이를 만들어서, GPU 에게 건내준다음에 그려줘서 결과물을 받아서 화면에 출력한다. 즉 이떄 하나의 종이로만 받지말고 CPU 와 GPU 가 사용하는 종이를 두개를 만들어서 바꿔주면 된다. 즉 하나는 현재 화면을 그리고, 나머지 하나는 외주의 결과물을 담으면 된다. 즉 이러한 개념이 Double Buffering 이라고 한다. 결론적으로 BackBuffer 에서는 GPU 의 결과값을 담고, 현재화면에서는 현재의 buffer 를 담고있으면 된다.

SwapChain 에는 backBuffer 의 index 를 가지고 있고, renderTarget 이 array 로 존재하기 때문에 buffer 의 Count 에 따라서 renderTarget 이 변경이된다. 그리고 SwapChain 에서 구현해야되는 함수는 어떤 Index 에 renderingTarget 이 있는지와, 현재 어떤것을 뿌려야해는지 등 있다.

그 다음 descriptorHeap 이다. 일단 외주를 하는건 좋다. 하지만 기안서 즉, 어떤 어떤것을 실행해주고, 어떤 형태의 양식을 넘겨줘서 일을 맡겨야 속이 시원하다. 아무형태나 같다주면, 이게 뭐야 쓰레기인가 하면서 처리하지 못한다. 즉 descriptorHeap 에서는 각종 리소스를 어떤 용도로 사용하는지 꼼꼼하게 적어서 넘겨주는 역활이다.

DescriptorHeap 같은 경우 DX12 에서 생겨난건데, 이 descriptor Heap 을 통해서 RenderTargetView(RTV) 를 설정한다. 즉 기안서를 실행을 하면, 어떠한곳에서 Handle 을 하라고 넘겨줄거고, SwapChaing 에서 descriptor 를 실행해 그리고, RenderTargetView 를 생성해서, 만들어지면 SwapChain 에서 바꿔준다. 그리고 Resource 를 넘겨줄때, 그냥 넘겨주는게 아니라, 기안서(View) 를 통해 넘겨줘야하는데 backbufferview 로 넘겨주는 형식이다. 결론적으로 말해서 DescriptorHeap 은 어떤 Array 의 형식으로 이뤄져있는데, array 의 element 가 여러개의 View 를 가지고 있고, 그게 원본 resource 를 가르키고 있다.

즉 Engine main code 에서는 Render 를 할떄 CommandQueue 의 RenderBegin 과 RenderEnd() 가 계속 돌아간다.

그 다음 Root Signature 이다. 이건 약간 결재 또는 계약서이다. 즉 무엇을 대상으로 데이터를 가공하는지를 GPU 가 알고 있어야한다. 즉 CPU 에서 GPU 로 일감을 넘길때 어떤 data 나 resource 를 주는데 가공방법이나 이런걸주는데 서명을 해야 된다. 이것이 root signature 이다. 더나아가서 어떤 버퍼에서는 이런걸을 하기로 계약을하고 다른 버퍼에서는 이걸 계약하겠다라는 승인이 필요하다.

Root Signatuer 을 이해하려면 CPU 와 GPU 의 메모리구조의 이해가 필요하다. Cache 나 Register 안은 메모리의 양도 적지만 빠르다. 하지만 물리적인 관점에서 봤을때 우리가 사용하는 하드메모리는 평민같아서 되게 멀기도하고 딱히 외주를 넘겨줄때 승인이 필요하지 않다. 하지만 Register 나 Cache 같은 친구들은 비싼친구들이기 때문에 조금 승인받는것도 빡세다. 그래서 root signature 같은 경우 option 이 필요하다. 즉 성명 Type 이 필요하다. 처음에는 API 의 칸을 설정을 해주고, 중간에는 어떠한 용도로 사용 될지가 들어가고, 마지막은 shader programming 에서 register 의 bind 할 slot 이라고 생각하면 된다. 여기에서 중요한게 data 를 실제로 넣으려고 하는게 아니라, 계약서에 이러한 이러한 정보를 담겠다가 맞다. 즉 Resource 를 어떻게 어디서 붙힌다라는걸 설명해주는 문서 라고 생각하면 된다. 그리고 Register 를 선택한 이후에, Option 이 들어가는게 Pipeline 안에 stage 들을 보여줄수있는 옵션도 있다.

Root signautre 를 사용할때, constant buffer 를 사용하는데 CPU 와 연결되어있는 메모리를 GPU 에 있는 메모리에 옮겨준다음에, register 에 buffer 의 주소값만 던져주고, 나중에 CommandQueue 에서 처리할때, GPU가 일을 할수 있게 된다.

더 자세한 정보는 여기서 보면 된다 Constant Buffer View.

그 다음 Meshshader 이다. Mesh 같은 경우 즉 정점의 정보들을 가지고 있어야한다. resource 를 바로 보내는게 아니라 descriptorHeap 처럼 bufferview 라는걸 보여줘야한다. 여기에서 중요한건 뭐냐, 일단 vertex 의 정보를 buffer 에 넣어서 CPU 에서 만들어준 다음, 그 buffer 를 GPU 메모리에 할당을 해준다. 그렇다면 Mesh 가 Render 되는 부분은 CommmandQueue 안에 RenderBegin 과 RenderEnd 에 나머지부분을 그려주는 형태이다. 그리고 그리는 방식 또는 Topology 형태를 Parameter 로 지정이 가능하고, vertex buffer view 를 slot 에 끼워넣어서 vertex 를 그릴수 있다.

Shader 는 무엇이냐? 라고 묻는다면, 바로 외주인력들이 무엇을 해야하는지 말해주는거다. 뭔가 거대한 거물들이 계약서도 받고 도장도 찍었으면, 이제 그 거물급들이 각각의 인력들에게 오케이 이제 오늘부터 이거 해야되라고 말하는것이다. 즉 일감 기술서 이다. shader 프로그램을 읽고 로딩해서 실행한다. shader 에는 pixel shader (part of fragment shader) 와 vertex shader 가 존재한다. 잠깐 shader program? shader program 이 다행히 c++ 와 비슷한 syntax 를 가지고 있어서, 짜기도 쉬운데 문제는 Main 에서 Parameter 를 한정적으로 받을수 있다는게 문제이다. 아래의 Graphic Pipeline 을 한번 봐보자. 아래의 그림을 보면 Input Assember Stages 에서는 멋대로 data 값을 넘겨줄수 없다.(다른 stage 와 달리)

Resource

Memoir

2022 Memoir

항상 연말에는 페이스북에 많은 개발자 회고록이 올라온다. 계속 보고만 있었는데, 많은 사람들이 여러 책을 읽고, 자기 개발을 하는거 보고 있다. 나도 나름대로 노력을 한것 같다. 그리고 2022 년에는 되게 많은 일들이 있었던것 같다. 그리고 한국 1년 개발자가 되어있는 나를 보며 회고록을 쓰려고 한다.

Adjustment & Changes

일단 저번해가 지나고 나서 많은 공부와 노력이 따로 필요했었다. 새로운 팀으로 넘어가 지속적인 개발이 필요했었고, 그때만큼은 내 몸을 아끼지 않았다. 그래서 운동은 따로 했었지만 허약해 있었고, 과호흡이 왔어서, 응급실을 갔었고 또 코로나에 걸려, 정해진 날짜에 훈련소를 가지 못했어서, 되게 당황했었지만, 나름 그 주 뒤에 바로가서 훈련받고 오고 무사히 돌아왔다. 한국에 적응을 할 시간 조차 없이 너무 이번 한해에 많은 일들이 있었다. 미국에서 바로 한국으로 넘어올시기에 몸이 않좋아 입원, 다음에 바로 취직, 훈련소.. 너무 많은일들이 짧고 굵게 휘몰아 쳤지만, 그래도 나름 생활을 잘했던 2022 년 이였다.

Development Perspective

개발은 정말 많은걸 목표로 두고 있었다. 일단 파이썬으로 개발해서, OpenGL Python, gRPC Interface 에 맞게 request & receive 를 하는 interface 를 만들었었고, 자율주행에서 중요한 맵과 맵을 합치는 것과, Command Pattern 을 이용해서 Undo / Redo Interface 만드는거 까지 했었다. 이걸로 더 나아가 최근에 특허까지 승인이된 우리 회사 Scenario Runner Software는 수많은 Refactoring 과 PyQt UI 구현 및 데이터 전달 부터 OpenScenario에서 정의된 시나리오를 실현시키기 까지 엄청난 노력을 썻다고 해도 무관하다. 나는 그 과정속에서 깔끔한 코드, 그리고 가독성이 높은 코드를 짜려고 많이 고민을 했었다. 사실 협업이라는게 쉽지 않은것도 맞다. 난 코드를 짤때 최대한 남들에게 피해가 안가게끔 짜는 편이였지만 그 방법이 틀리다는걸 잘알고 있다. 조금씩 조금씩 바뀌는것 보다는 조금 일이 많더라도, 방향을 잘잡아야한다 라는 말이 끝까지 와닿었던 Sid meyer 의 말도 참 내 경험상 많은걸 build up 해주었다.

2023 New Goal

2023 년은 되게 많은 일들이 있을것 같으면서도 어느정도의 변화의 짐작이 간다. 일단 일적으로는 MORAI SIM V2 작업을 새로운 언어로 포팅하는 작업을 해야한다. 정말 큰 일이기도 하면서도 어느정도의 시간이 흐를지 반복 테스틑 얼마나 해야될지 가늠이 안가지만, 팀의 성향이 다른 team 들의 영향을 많이 받는 Working Group 이라 어쩔수 없다. 하지만 정말 필요한 스킬은 Communication Skill 이다. 이제는 한국에 적응할 시기가 충분히 지났다.

일단 아래와 같이 나의 Goal 을 맞춰나갈꺼다. 그리고 잘지켜졌으면 좋겠다라는 바램이 있다.

Career 측면에서 일단 세갈래로 크게 나가고 싶다.

  1. Computer Vision
  2. Computer Graphics
  3. Game Dev in C++

일단 Vision 쪽에서는, 카메라에 대한 기초와 3D Geometry 에 대한 공부를 시작 해야한다. 또 이거 말고도 2D 에서 사용되는 feature matching.. 등 pyramid 를 다시 복습하고 싶다. 그리고 graphics 는 DirectX11 이나 Vulkan 으로 rendering pipeline 을 구현 및 설명할수 있는 정도가 되는게 목표이다. 그 이후에 딥러닝을 병합한 dlss 도 한번 살펴봐야한다.

3번쨰는 굉장히 많은 노력이 필요한것같다. 대체적으로 게임 서버 운영 및 다중 플레이어 호환 등 시작해봐야 되며, 게임에 필요한 수학 (especiall on geometry), path planning algorithm, 등 algorithm 공부도 필요하다. 그리고 마지막으로 나만의 게임을 만들어보는게 이번년의 목표이다.

물론 계획이 rough 하지만 필요한부분을 간추렸고, direction 을 못찾았을때, 항상 지표가 되는게 되었으면 좋겠다. 이직도 4월이나 5월에 생각은 하고 있지만 경제상황을 봐서 지혜롭게 움직였으면 좋겠다는 생각이든다.

그리고 책을 이번엔 20권을 목표로 읽을 예정이다. 가끔씩은 이해를 잘못해서 그냥 넘어가는 경향이 있는데, 책을 읽는데만 그치는게 아니라, 생각하는 힘도 키워나아가야한다.

그리고 건강 및 가족 측면에서는 일단 운동은 계속 꾸준히 해야한다. 그리고 단커피와 술을 줄여서 간수치를 내리는것도 목표이다. 가족같은 경우는 더더욱 지금 상태를 유지하고 싶고, 매순간 잘하기로 마음을 먹었다. 취미는 서핑과 기타를 계속 치는게 목표이다. 하지만 많은 (검)토끼를 잡을수 있으니 잠시 내려 놔야할때는 내려놓을 필요도 있어보인다.

2023 년 어려울수도 있고, 그냥 문안하게 잘 지나갈수도 있지만, 조금 더 열심히, 부족한게 있으면 조금더 채워나가는 식으로 갔으면 좋겠다.

Image Preprocess in Graphics

Handling Array

Image 를 처음으로 Screen 으로 봤을때, 2D 로 보일거다. OpenCV 를 사용했었더라면, Image Watch 로 봤을때, 각 pixel 값이 2D image 에 잘 저장이되어 보인다고 볼수 있다.

사실은 내부적으로 어떻게 되는지? 를 궁금해할수있다. C++ 에서의 운영체제에서는 데이터를 받을때 1차원형태로 받는다. 만약에 image 의 pixel 값이 [0, 255] 을 값을 Normalize 를 0 과 1 사이로 한다면, 한 element 에 들어가는것은 바로 float 값일거다. 그래서 float* myImg = new [width * height] 이런식으로 해서 Image를 1D 로 보관한다. 즉 어떻게 Indexing 하느냐에 따라서 2D 로 배열을 바꿀수 있다.

주로 기초적인 질문일수 있지만, API 를 사용하다보면 놓칠수도 있다. 왼쪽 가로축으로 시작해서 오른쪽으로 한칸 한칸 움직이고(Column), 그 다음 row 에 가서 위와 같은 방법으로 indexing 을 할 수 있다. 예를들어서 (0, 0), (1, 0), (2, 0) .. 이런식으로 가다가 두번째 row 에서는 (0, 1) (1, 1), (2, 1) .. 이런식으로 가서 맨 아래의 element 에서는 (#column, #row) 가 되는식으로 될것이다.

그렇다면 다시 거꾸로 해서, 2D image 에서 (2, 3) 이라는 데이터가 있다고 가정하자. 그리고 (2,3) 에 가서 data 를 변경 한다고 가정하면, 우리에게 주어진건 1D data 이기 때문에, 17 번째의 Index 를 찾아야한다. 어떤 인덱스 (i, j) 에서 1차원 index 를 가지고 올수 있는 방법은 i + width * j 이다.

Handling Screen(Image)

일단 Graphics 관점에서 뭔가 screen 에다가 표현을 하고 싶다고 한다면, DirectX11 을 사용해서 Pixel 값들을 움직일 수 있다. 여기서 Vec4 라는 구조체를 넣어서, screen 좌표에 있는 모든 pixel 값들을 하얀색으로 지정해준다. 그런 다음에 Update 에서 while 문에서 호출 했었을때, screen 좌표에있는 Pixel 을 빨간색을 칠해주고 그다음 pixel 을 가서 또 칠해준다. 즉 빨간색 pixel 이 움직이는것 처럼 보이게 할수 있다.

아래 부분의 주석으로 되어있는 코드는 CPU 에서 Memory 를 Map 을 만들어주고, memory copy 를 해서 GPU 에 넘겨주는 코드 부분이다. 이게 사실 Bottle Neck 이 될수 있다. 더 자세한건 Grpahics Pipeline 을 한번 참고 하기를 바란다.

struct Vec4
{
    float v[4];
}

void Update()
{
    Sleep(300);
    static int = 0;
    std::vector<Vec4> pixels(canvasWidth * canvasHeight, Vec4{1.0f, 1.0f, 1.0f, 1.0f});
    pixels[i] = Vec4{ 1.0f, 0.0f, 0.0f, 1.0f };
    if (i >= pixels.size() - 1)
    {
        i = 0;
    }
    else
    {
        i++;
    }

    // Update Texture Buffer
    D3D11_MAPPED_SUBRESOURCE ms;
	deviceContext->Map(canvasTexture, NULL, D3D11_MAP_WRITE_DISCARD, NULL, &ms);
	memcpy(ms.pData, pixels.data(), pixels.size() * sizeof(Vec4));
	deviceContext->Unmap(canvasTexture, NULL);
}

제일 Image 를 해석하려면, 제일 좋은게 뭐일까라고 물어본다면 바로 이미지를 읽고 저장하는게 제일 중요하다. OpenCV 를 사용해서 Image 를 읽는것도 있지만, 제일 쉬운건 Python 에서 pip 관리 하는것처럼 vcpkg 를 사용해서 stb 를 사용하는게 제일 좋다. (단 image 가 너무 커질때는 조심해야한다.)

#define STB_IMAGE_IMPLEMENTATION
#include <stb_image.h>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <stb_image_write.h>
#include <algorithm> // std::clmap(c++ 17)
class Image
{
public:
    int width = 0, height = 0, channels = 0;
    std::vector<Vec4> pixels; 

    void ReadFromFile(const char* fileName);
    void WritePNG(const char* filename);
    Vec4& GetPixel(int i, int j);
}

void Image::ReadFromFile(const char* fileName)
{
    // 대부분의 img 는 0 ~ 255 값을 가지고 있기때문에 unsigned char 로 저장
    unsigned char* img = stbi_load(fileName, &width, &height, &channels, 0);

    if (width) {
		std::cout << width << " " << height << " " << channels << std::endl;
	}
	else {
		std::cout << "Error: reading " << filename << " failed." << std::endl;
	}

    // channel 이 3 이나 4 일걸 가정
    pixels.resize(width * height);
    for(int i = 0; i < width * height; i++)
    {
        pixels[i].v[0] = img[i * channels] / 255.0f;
		pixels[i].v[1] = img[i * channels +1] / 255.0f;
		pixels[i].v[2] = img[i * channels +2] / 255.0f;
		pixels[i].v[3] = 1.0f;
    }

    delete [] img;
}

void Image::WritePNG(const char* filename)
{
	// 32bit -> 8bits
	std::vector<unsigned char> img(width * height * channels, 0);
	for (int i = 0; i < width * height; i++)
	{
		img[i * channels] = uint8_t(pixels[i].v[0] * 255.0f); 
		img[i * channels + 1] = uint8_t(pixels[i].v[1] * 255.0f);
		img[i * channels + 2] = uint8_t(pixels[i].v[2] * 255.0f);
	}

	stbi_write_png(filename, width, height, channels, img.data(), width * channels);
}

Vec4& Image::GetPixel(int i, int j)
{
	i = std::clamp(i, 0, this->width - 1);
	j = std::clamp(j, 0, this->height - 1);

	return this->pixels[i + this->width * j];
}

위의 함수를 적절히 이용해서, 아래와 같은 Image Data 를 읽고 저장할수 있다. 사진의 해상도가 높다면 깨질수도 있으니 확인이 필요하다.

Convolution

Deep Learning 에서 Image Object Detection 을 해봤더라면 Convolution Layer 라는 걸 사용해 본적이 있을것이다. 그리고 OpenCV 에서 Kernel size 아니면 Masking 을 사용하거나 적용해서 Edge 를 더 나타내거나, 여러 Blur 종류 들을 볼수 있을것이다. Convolution 의 내용은 아래 6 Basic Things to Know about ConvolutionA Basic Introduction to Convolutions 를 참고하기 바른다.

Computer Graphics 측면에서 Convolution Filter 를 사용하게되면 연산량이 많아진다. 그래서 Separable Convolution 을 사용한다. Separable Convolution 같은 5 x 5 의 Kernel 이 있다고 하면 Middle point 에서 row 값을 평균을 내고, column 값에서 평균을 낸다. 즉 중앙을 기준으로 row 를 평균, column 을 평균을 내는 방식이다.

그렇다면 Box Blur 를 구현을 해보자. 구현 내용은 아래와같다. GetPixel 함수에서 clamping 을 했기 때문에 만약에 Padding 이 없을 경우, -2, -1 값들은 0, 1 로 push 하게 된다.

for (int j = 0; j < this->height; j++)
{
	for (int i = 0; i < this->width; i++)
	{
		Vec4 neighborColorSum{ 0.0f, 0.0f, 0.0f, 1.0f };
		for (int si = 0; si < 5; si++)
		{
			Vec4 neighborColor = this->GetPixel(i + si - 2, j);
			neighborColorSum.v[0] += neighborColor.v[0];
			neighborColorSum.v[1] += neighborColor.v[1];
			neighborColorSum.v[2] += neighborColor.v[2];
		}
		pixelsBuffer[i + this->width * j].v[0] = neighborColorSum.v[0] * 0.2f;
		pixelsBuffer[i + this->width * j].v[1] = neighborColorSum.v[1] * 0.2f;
		pixelsBuffer[i + this->width * j].v[2] = neighborColorSum.v[2] * 0.2f;
	}
}

for (int j = 0; j < this->height; j++)
{
	for (int i = 0; i < this->width; i++)
	{
		Vec4 neighborColorSum{ 0.0f, 0.0f, 0.0f, 1.0f };
		for (int si = 0; si < 5; si++)
		{
			Vec4 neighborColor = this->GetPixel(i, j + si - 2);
			neighborColorSum.v[0] += neighborColor.v[0];
			neighborColorSum.v[1] += neighborColor.v[1];
			neighborColorSum.v[2] += neighborColor.v[2];
		}
		pixelsBuffer[i + this->width * j].v[0] = neighborColorSum.v[0] * 0.2f;
		pixelsBuffer[i + this->width * j].v[1] = neighborColorSum.v[1] * 0.2f;
		pixelsBuffer[i + this->width * j].v[2] = neighborColorSum.v[2] * 0.2f;
	}
}

사용을 하면, 아래와 같은 그림이 결과로 저장이되어 나온다.

Gaussian Blur

가우시안 Blur 를 사용하려면, weight 값을 줘야 된다. const float weights[5] = { 0.0545f, 0.2442f, 0.4026f, 0.2442f, 0.0545f };

위에서 Box Blur 와 마찬가지로 해결해보면, 아래와 같은 코드가 나온다.

for (int i = 0; i < this->width; i++)
{
	Vec4 neighborColorSum{ 0.0f, 0.0f, 0.0f, 1.0f };
	for (int si = 0; si < 5; si++)
	{
		Vec4 neighborColor = this->GetPixel(i + si - 2 , j);
		neighborColorSum.v[0] += neighborColor.v[0] * weights[si];
		neighborColorSum.v[1] += neighborColor.v[1] * weights[si];
		neighborColorSum.v[2] += neighborColor.v[2] * weights[si];
    }
	pixelsBuffer[i + this->width * j].v[0] = neighborColorSum.v[0];
	pixelsBuffer[i + this->width * j].v[1] = neighborColorSum.v[1];
	pixelsBuffer[i + this->width * j].v[2] = neighborColorSum.v[2];
}

for (int j = 0; j < this->height; j++)
{
	for (int i = 0; i < this->width; i++)
	{
		// 주변 픽셀들의 색을 평균내어서 (i, j)에 있는 픽셀의 색을 변경
		// this->pixels로부터 읽어온 값들을 평균내어서 pixelsBuffer의 값들을 바꾸기
		Vec4 neighborColorSum{ 0.0f, 0.0f, 0.0f, 1.0f };
		for (int si = 0; si < 5; si++)
		{
			Vec4 neighborColor = this->GetPixel(i, j + si - 2);
			neighborColorSum.v[0] += neighborColor.v[0] * weights[si];
			neighborColorSum.v[1] += neighborColor.v[1] * weights[si];
			neighborColorSum.v[2] += neighborColor.v[2] * weights[si];
		}
		pixelsBuffer[i + this->width * j].v[0] = neighborColorSum.v[0];
		pixelsBuffer[i + this->width * j].v[1] = neighborColorSum.v[1];
		pixelsBuffer[i + this->width * j].v[2] = neighborColorSum.v[2];
	}
}

Bloom Effect

Bloom 효과 같은경우는 밝은 Pixel 은 가만히두고, 어두운 Pixel 을 전부다 검은색으로 둔다음에 Gaussian Blur 를 사용한다. 그런다음에 원본이미지와 Blur 된 이미지를 더하면, Bloom Effect 가 일어난다. 일단 어두운 Pixel 을 전부다 검은색으로 바꾸는게 중요하다. 아래의 Resource 에서 Relative Luminance 를 참고하길 바란다. 그래서 이 식으로 하면 된다. Relative Luminance Y = 0.2126*R + 0.7152*G + 0.0722*B.

구현 방법은 아래와 같다. 즉 Pixel 을 가지고 와서 Relaitve Luminance 를 곱한 이후에, 어떤 threshold 에 넘는다고 하면 0 으로 바꿔치기하는 기술이다.

for (int j = 0; j < height; j ++)
	for (int i = 0; i < width; i++)
	{
		auto& c = this->GetPixel(i, j);
		const float relativeLuminance = c.v[0] * 0.2126 + c.v[1] * 0.7152 + c.v[2] * 0.0722;
		if (relativeLuminance < th)
		{
			c.v[0] = 0.0f;
			c.v[1] = 0.0f;
			c.v[2] = 0.0f;
		}
	}

그런다음 Gaussian Blur 함수를 call 한다음에 원본이미지에 더하는 코드는 아래와 같다. pixelBackup 같은경우 원본이미지의 복사본을 들고 있는거다.

for (int i = 0; i < pixelsBackup.size(); i++)
{
	this->pixels[i].v[0] = std::clamp(pixels[i].v[0] * weight + pixelsBackup[i].v[0], 0.0f, 1.0f);
	this->pixels[i].v[1] = std::clamp(pixels[i].v[1] * weight + pixelsBackup[i].v[1], 0.0f, 1.0f);
	this->pixels[i].v[2] = std::clamp(pixels[i].v[2] * weight + pixelsBackup[i].v[2], 0.0f, 1.0f);
}

결과의 이미지를 참고 하면,

relative luminance 를 통한 어두운 pixels 를 검은 pixel 로 바꿨을때

그 이후 Gaussian Blur 했을때

마지막으로 원본 데이터를 aggregate 했을때

Resource

Vulkan Introduction

The Vulkan is the graphical API made by Khronos providing the better abstraction on newer graphic cards. This API outperform? the Direct3D and OpenGL by explaining what it perform. The idea of the Vulkan is similar to the Direct3D12 and Metal, but Vulkan is cross-platform, and it can be used and developed in Linux or Window Environment.

However, the drawback of this could be is there will be many detailed procedure while using the Vulkan API, such as creating the buffer frame, managing the memory for buffer and texture image objects. That means we would have to set up properly on application. I realized that the Vulkan is not for everyone, it is only for people who’s passionate on Computer Graphics Area. The current trends for computer graphics in Game Dev, they are switching the DirectX or OpenGL to Vulkan : Lists of Game made by Vulkan. One of the easier approach could be is to learn and play the computer graphics inside of Unreal Engine and Unity.

By designing from scratch for the latest graphics architecture, Vulkan can benefit from improved performance by eliminating bottlenecks with multi-threading support from CPUs and providing programmers with more control for GPUs. Reducing driver overhead by allowing programmers to clearly specify intentions using more detailed APIs, and allow multiple threads to create and submit commands in parallel and Reducing shader compilation inconsistencies by switching to a standardized byte code format with a single compiler. Finally, it integrates graphics and computing into a single API to recognize the general-purpose processing capabilities of the latest graphics cards.

There are three preconditions to follow

  • Vulkan (NVIDA, AMD, Intel) compatible graphic cards and drivers
  • Expereince in C++ ( RAII, initializer_list, Modern C++11)
  • Compiler above C++17 (Visual Studio 2017, GCC 7+, Clang 5+)
  • 3D computer Graphics Experience

Inline Function

가끔씩 코드를 쓰다보면, 가독성과 최적화를 동시에 잡아야할 필요가 있다. 이럴때 C++ 에서는 대표적으로 inline 함수를 쓸수 있다. 바로 코드를 봐보자.

#include <iostream>
using namespace std;

inline int min(int x, int y)
{
    return x > y ? y : x;
}

int main()
{
    cout << min(5, 6) << endl;
    cout << min(3, 2) << endl;

    cout << (5 > 6 ? 6 : 5) << endl;
    cout << (3 > 2 ? 2 : 3) << endl;
    return 0;
}

위의 코드를 보면 일단 main 함수의 2번째 줄은 function call 을 하는게 보인다. 하지만 inline 이라는 키워드를 쓰게 되면 구현부를 구지 function call 하지 않고, 바로 함수의 능력을 바로쓸수 있다는 장점이 있다. 하지만 inline 을 물론 다 함수에 붙여놓으면 이상하고, 그런다고 성능이 좋아지지는 않는다. 왜냐하면 compiler 해결하는 속도는 계속 증가하다보니까, inline 을 쓰든 안쓰든 성능 보장이 없다.

Pagination


© 2021. All rights reserved.