1) CPU vs GPU




컴퓨터가 화면을 그린다는 것은 화면의 해상도에 해당하는 수의 Pixel들 마다 각각의 색상값을 결정해서 출력해준다는 뜻과 같다. 예를 들어 1920 x 1080 해상도의 HD화질에 60hz주사율의 모니터라면 매 프레임마다 2073600개의 픽셀들의 색상값을 계산해서 출력한다는 것이다. 물론 요즘 CPU의 어마어마한 연산량과 멀티코어 기술만 있다면 이 정도 연산은 아무것도 아닌 것처럼 보일지라도, CPU 자원이 오직 그래픽 연산에만 쓰이는 것은 아니기 때문에 화면을 그리는 것을 CPU 혼자 처리하기에는 벅찰 것이다. 위의 영상은 그것을 보완하기 위해 등장한 GPU의 개념에 대해 보여주고 있다. 물론 GPU는 CPU와 물리적인 아키텍처부터 다를 뿐 더러 영상에서 처럼 픽셀 하나당 GPU하나가 배당되는 것도 아니지만 그럼에도 불구하고 CPU만으로 처리하기에는 벅찬 그래픽 연산을 병렬처리라는 개념을 도입해 극복하고 있다는 것을 보여주고 있다. GPU가 CPU와 다르다는 단적인 예로 GPU는 기본적으로 Interpolation 연산이 빠르게 처리될 수 있도록 설계되어 있다. 아래의 코드를 보자.


resultRGB = colorRGB_0 * (1.0 - alpha) + colorRGB_1 * alpha;
resultRGB = colorRGB_0  + alpha * (colorRGB_1 - colorRGB_0);


위와 아래의 코드는 Algebra관점에서 보면 같은 수식이지만 GPU에서는 처리하는 속도가 아래의 것이 더 빠르다.(그리고 부동소수점 누적오차도 더 적다.) 이것을 Multiply, then Add Operation 즉 MAD Operation이라고 하는데 GPU는 이러한 형태의 연산이 Single-cycle만에 계산되도록 설계되어있기 때문이다. GPU에서는 Interpolation연산이 매우 빈번하게 일어난다. 한 Pixel의 특정값을 결정할 때 두 개의 기준점의 사이값을 취하는 것이 일반적이기 때문이다. 그런데 a와 b사이의 Interpolation 값을 구하는 공식이 x = a + t(b-a) (0<t<1) 즉, MAD Operation인 것을 보면 이러한 속성이 얼마나 유용한 것인지 알 수 있다. CPU의 코드를 짤때도 이러한 MAD Operation이 연산횟수도 더 적고 누적오차도 적어지기 때문에 습관적으로 이것을 사용하면 좋다.



이러한 GPU의 연산능력을 보다 범용적으로 사용하기 위해 General-Purpose GPU(GPGPU)라는 개념이 등장했다. NVIDIA 그래픽 카드 한정으로 CUDA라는 SDK를 활용할 수 있지만 OpenGL과 같이 OpenCL이라는 라이브러리를 사용해서도 GPGPU 프로그래밍을 할 수 있다. 하지만 모든 종류의 연산에서 이러한 GPGPU가 CPU보다 항상 우월한 것은 아니고 일반적으로 서로 종속관계가 없는 다차원의 데이터 즉, 병렬로 연산을 구성하기 좋은 데이터를 처리하는데 유용하다. 가장 좋은 예가 이미지 프로세싱이다. 1920*1080짜리 이미지는 픽셀들이 모두 독립적이기 때문에 2073600차원의 벡터라고 볼 수 있는데(단색 이미지일 때) 어떤 이미지 필터를 개발한다면 픽셀값을 독립적으로 계산하는 것이기 때문에 CPU보다는 GPU를 이용하는 것이 훨씬 효율적일 것이라는 뜻이다.



하지만 아직까지 우리가 사용하는 모든 OS는 CPU기반으로 동작하기 때문에 GPU 연산은 필연적으로 CPU와 연동할 수 밖에 없다. 메모리관리와 스케줄링 등을 GPU로 구현한 OS가 등장한다면 이야기는 달라지겠지만 아마 그보다는 멀티코어 컴퓨팅의 발전을 기다리는 것이 훨씬 합리적일 것이다. 때문에 GPU로 방대한 양의 데이터를 처리하기 위해서는 RAM이나 Disk의 Memory에 있는 데이터를 GPU와 연동된 Memory Unit와 주고받는 과정이 추가된다. 일반적인 컴퓨팅에서도 가장 병목이 일어나는 곳이 Disk Read/Write인 만큼 이 작업이 결코 가볍지 않기 때문에 GPU를 이용하는 것에 있어 세심한 주의가 필요하다. 자칫하면 병렬작업을 CPU로 처리하는 것보다도 느려질 수 있기 때문이다.

2) State Machine

그럼 이 GPU라는 연산장치는 어떻게 사용하는 걸까? 애초에 x86/x64 아키텍처의 연산기에서 돌아가도록 컴파일되는 코드들을 가지고 어떻게 아예 설계가 다른 연산기에서 동작하도록 한다는걸까? OpenGL의 동작원리를 이해하기 위해서는 이 질문에 대해 먼저 생각해볼 필요가 있다. OpenGL은 다른 프로그래밍 언어가 CPU에서 동작하는 언어로 직접적으로 GPU의 자원을 관리할 수 있도록 해주지는 않는다. 대신 GPU의 자원을 하나의 상태기계(State Machine)으로써 취급하여 명령을 전달한다. 따라서 OpenGL은 오로지 헤더로만 이루어져 있고 이에 대한 구현은 GPU 내부에 이루어져 있다. 이런 관점에서 보면 OpenGL은 어떤 라이브러리나 SDK라기 보다는 선언 그 자체로 GPU의 스펙인 것이다. 

다시 정의해보자면 OpenGL의 함수들은 GPU를 포함한 Graphics Hardware라는 상태기계를 제어할 수 있는 명령들이라고 할 수 있다. 이러한 명령집합을 우리의 코드와 함께 엮어서 다양한 방면으로 응용할 수 있는 것이다. 상태 기계란 현재의 상태를 정의하는 수많은 변수의 집합과 같다. 따라서 상태기계의 제어 명령어는 크게 두가지로 나뉠 수 있다. 하나는 상태기계의 변수를 변경하는 State-Changing 명령과 다른 하나는 상태기계의 현재 상태를 사용하여 특정 동작을 수행하는 State-Using 명령이다. 때문에 일반적으로 Native Graphics Context에서는 그림 그리는 함수 한두 줄 만으로도 그림을 그리지만 OpenGL에서 그림을 그리기 위해서는 State-Changing과 State-Using 명령을 순서에 맞게 잘 조합해서 사용해야 하기 때문에 이보다 좀 더 복잡하다.

아래는 화면에 간단하게 삼각형을 그리는 코드이다. 실제로 그림을 그리라고 명령을 내리는 코드는 가장 밑의 세 줄뿐이지만 이것을 수행하기 위해 위에서 어마어마한 전처리가 필요한 것을 볼 수 있다. 이것에 대해 이해하기 위해서는 OpenGL의 렌더링 파이프라인에 대해 이해하고 OpenGL의 어떤 상태집합을 사용할 수 있는지 알고 있어야 한다. 하지만 언제나 OpenGL은 거대한 상태변수의 집합으로 이루어진 상태기계라는 사실을 염두에 두고, 어떤 것이 State-Changing인지 어떤 것이 State-Using인지 구별하며 접근한다면 보다 어렵지 않게 OpenGL과 가까워질 수 있을 것이다.

const char *vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
"   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
const char *fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
"   FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
 
 
float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};
 
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
 
 
int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
 
 
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

위의 코드를 실행하면 아래와 같은 결과가 나온다. 삼각형의 색깔은 어디서 결정된 것인지 재미로 한 번 찾아보자.



3) OpenGL Object

앞에서 설명했듯이, OpenGL은 상태기계로써 다양한 상태변수의 집합으로 이루어져 있다. 이 상태변수가 바로 이제부터 다룰 OpenGL Object이다. Object의 종류에는 Texture, RenderBuffer, FrameBuffer, VertexArrayBuffer, UniformBuffer, ShaderProgram 등이 있다. 이러한 Object들을 State-changing 함수를 이용해 값을 설정하여 렌더링에 필요한 정보를 저장한 뒤, State-using 함수를 이용해 화면에 프레임을 렌더링하는 것이다. OpenGL은 렌더링 파이프라인이 이미 정해져 있지만 이 Object들을 개발자가 자유롭게 구성할 수 있기 때문에 다양한 렌더링을 구현할 수 있는 것이다. 아래의 그림을 보자. 버퍼에 저장된 값을 불러온 뒤, Vertex Shader와 Fragment Shader가 실행되고(중간에 Geometry Shader가 종종 포함되기도 한다.), 결과값이 화면에 렌더링 된다. 이러한 과정을 통틀어 렌더링 파이프라인이라고 한다. 예를 들면, Texture Object에 표현하고 싶은 물체의 질감정보를 입력하고, Vertex Object에 3D 물체에 대한 다각형 꼭짓점 정보를 입력한 뒤, Uniform Object에 카메라 변환에 필요한 회전행렬 정보 등을 입력한 뒤 ShaderProgram을 링크한 다음 개발자가 원하는 시점에 OpenGL의 State-Using 함수를 실행하면 화면에 그림이 그려지는 것이다.


+ Recent posts