본문 바로가기

Graphics Note

OpenGL 강좌 - 6. GPU 파이프라인 그리고 셰이더

저번 강좌에서 간단하게 언급했듯 어떤 물체를 어떻게 그리는가를 정의하는 부분이 셰이더라 했었다. 본격적인 셰이더 이해를 위해 GPU 파이프라인(pipeline)에 대해 간략히 소개할 것이다. 또한 필수 셰이더인 정점 셰이더 및 프래그먼트 셰이더에 대해서도 간을 좀 보려 한다. 정점 셰이더의 설명을 위해 정점 속성(vertex attribute)에 대해서도 함께 설명할것이다.


* GPU 파이프라인(pipeline)

* 정점 속성(vertex attribute)

* 정점 셰이더(vertex shader) 및 프래그먼트 셰이더(fragment shader) 맛보기


앞의 강좌에서 다섯가지 셰이더를 언급했었고, 셰이더란 무엇인지 두루뭉술 설명했었다. 하지만 아직까지도 감이 잘 안잡힐것이다. 셰이더를 보다 확실히 이해하기 위해서는 GPU의 데이터 처리 흐름, 즉, GPU 파이프라인에 대한 이해가 필요하다.



GPU pipeline


컴퓨터를 공부한다면 GPU는 빠르다는 얘기를 들어본적이 있을수도 있겠다. 그렇다면 GPU는 왜 빠르다고 알려졌을까? 이유는 GPU의 처리 방식 때문이다. GPU는 CPU와 달리 병렬처리를 기본으로 한다. 요즘 CPU도 쿼드 코어는 기본이고 멀티 스레딩(multi-threading) / 패러럴 프로세싱(parallel processing)은 굉장히 보편화 된 기술이라지만, GPU의 병렬성은 따라가지 못한다. 애초에 만들어진 목적이 다르니 당연한 얘기다.


GPU는 Graphics Processing Unit, 즉, 그래픽스 연산을 위해 특수하게 제작된 하드웨어다. 지금 OpenGL을 공부하고있는 입장에서 매우 친숙해져야할 하드웨어인셈이다. 그래픽스 연산은 3D 장면을 렌더링(rendering)하는것이라 뭉뚱그려 설명할 수 있다. 렌더링 된 결과 이미지가 충분히 빠른 속도로 반복 출력되고 있기 때문에 이런 블로그 글도 보고 마우스 커서 움직임도 보이고 게임을 하거나 영화도 볼 수 있는것이다.


이처럼 렌더링을 통해 사용자와 상호작용 하기 위해서는 '충분히' 빠른 속도로 한 장면 한 장면이 렌더링 되어야 한다. 즉, 모니터 화면의 모든 픽셀들에 대해 약 1/60초(60fps 기준) 안에 색상값을 결정해줘야 한다는 말이다. CPU로 이를 처리하려면 for 루프를 순회하면서 화면 각 픽셀값을 결정해주는 행위를 매우 빠르게 매번 반복해야한다는 뜻이다.


그런데 CPU는 이것 말고도 해야 할 일이 워낙 많아서 이런 곳에 낭비하기 아깝다. 그래서 탄생한것이 GPU이다. GPU는 화면 렌더링을 보다 빠르고 효율적으로 처리하기 위해 단일 능력은 조금 떨어지는 연산 코어를 굉장히 많은 수 때려박은 처리 유닛이다. 따라서 각 픽셀값을 병렬적으로 한번에 처리해버린다.


이처럼 각 픽셀값을 계산하는 일은 프래그먼트 셰이더(fragment shader)에서 하는 일이며, 추상적인 3D 장면으로부터 화면 픽셀값 계산을 위한 정보를 결정하는 일을 래스터화(rasterisation) 라고 부른다. 래스터화 단계 전에 정점 집합을 3D 공간상에 적절히 배치해줘야 할 것인데, 이 작업이 바로 정점 셰이더(vertex shader)가 하는 대표적인 일이다.



그림을 따라 다시 설명하면, CPU로 부터 입력된 정점 집합을 정점 셰이더가 적절히 배치하고 래스터라이저(rasterizer)가 래스터화 한 뒤, 프래그먼트 셰이더에서 각 픽셀값을 결정하면 화면에 출력되는것이다. 이러한 처리 흐름을 GPU 파이프라인이라 한다. (래스터화 전에 테셀레이션, 기하 셰이더 등 다른 파이프라인이 추가 가능하지만 지금은 무시하자)




그럼 본론으로 돌아와서 정점 및 프래그먼트 셰이더에 대해 알아보자. 먼저 정점 셰이더는,


호스트(CPU/어플리케이션)로부터 전달된 정점 속성(vertex attribute)들에 대한 장치(서버/GPU/셰이더)의 연산 방식


이라 설명할 수 있다. 여기서 보면 정점 셰이더는 정점 속성과 정점 속성에 대한 GPU의 연산 방식 이해가 키포인트 같다. 두개를 나눠서 파악할것인데, 연산 대상인 정점 속성에 대해 먼저 살펴보자.



정점 속성


정점 속성은 처음 나온 명칭이지만 쫄 것 없다. 정점(vertex)은 메쉬(mesh)를 정의하는 기본 단위였다. 직관적으로 정점은 위치(position) 데이터라 할 수 있지만, 컴퓨터 그래픽스 관점에서 정점은 이보다 더 많은 데이터를 포함한다. 예를 들어 각 정점에 대해 서로 다른 색상 데이터를 준다면 다음과 같이 아름다운 삼각형도 표현할 수 있다.



정점 속성은 색상 뿐 아니라 텍스쳐 좌표(texture coordinate), 법선 벡터(normal vector), 표면 벡터(tangent vector)등 다양한 데이터를 가질 수 있는데, 이로부터 정점 속성은 각 정점이 갖는 데이터(per-vertex data)라고 이해할 수 있다.


앞선 강좌에서 정점 버퍼 오브젝트(vertex buffer object)를 생성하고 버퍼 데이터를 정의했었다. 이때 지정한 데이터가 정점 속성의 집합이었던 것이다. 본 예제에서는 정점 속성에 가장 기본인 위치(position) 속성만 제공했지만, 앞으로 다양한 속성을 더해갈 것이다.


정의된 정점 버퍼 정보를 장치에 전달하는 작업은 전달하고자 하는 정점 버퍼 오브젝트를 활성화하는것으로 취급되며, 오브젝트의 활성화를 바인딩(binding)이라 한다.


정점 데이터의 전달과 인식 과정에서 중요한점은 CPU에서 전달하는 정점 속성의 형태와 셰이더가 받아들이는 형태가 일치해야한다는것이다. 이를 셰이더 인터페이스(shader interface)라 하는데, 기회가 되면 자세히 언급하기로 하고 지금은 셰이더 인터페이스를 정의하는 가장 일반적인 방법을 설명할것이다.


먼저 CPU 입장에서 정점 속성 형태를 어떻게 정의하는지 살펴보자. 먼저 활성화 할 정점 버퍼 오브젝트를 바인딩해준다.

glBindBuffer(GL_ARRAY_BUFFER, gVertexBufferObject);

다음으로 사용할 정점 속성을 활성화시킨다. 이는 glEnableVertexAttribArray 함수를 통해 이루어지며 몇 번째 인덱스의 속성을 활성화 할 것인지 인자로 지정해준다. 본 예제에서는 위치 속성만을 정의하였으므로 0번 인덱스만 활성화 해주면 된다.

glEnableVertexAttribArray(0);

위 함수로 정점 속성 인덱스를 활성화 했지만, 이 인덱스에 정점 속성이 어떤 형태인지는 아직 정의해주지 않았다. glVertexAttribPointer 함수를 통해 이를 지정해준다.

// glVertexAttribPointer(GLuint index,
//                       GLint size,
//                       GLenum type,
//                       GLboolean normalized,
//                       GLsizei stride,
//                       const GLvoid *pointer);
glVertexAttribPointer(0, 3, GL_FLOAT, 0, 0, NULL);

예제에 쓰인 형태는 0번 인덱스의 정점 속성 데이터가 float 타입의 3개의 엘리먼트를 가지는 데이터이며 정규화([-1,1] 혹은 [0,1] 범위로 만드는 연산) 할 필요 없으며 읽어들일 때 건너뜀(stride = 0) 없이 쭉 읽으면 된다고 말해준다. 또한 별도의 데이터 포인터를 지정해 주지 않았으니 정점 버퍼 오브젝트를 정의할 당시 glNamedBufferData 함수를 통해 지정해준 버퍼 데이터로부터 정점 속성을 읽으면 된다고 알려주었다.



GPU 입장에서는 이를 어떻게 받아들이는지도 맞춰줘야한다. 이를 위해서 OpenGL 그리고 GLSL은 세 가지 방식을 제공하며, 어떤 방식을 취하든 위에서 설명한 CPU 정점 속성 정의와 형태 및 인덱스가 동일하면 된다.


1. In-shader specification

정점 셰이더 코드에서 정점 속성의 인터페이스를 명세한다. 이때 레이아웃 한정자(layout qualifier)라는 GLSL 문법을 사용하여 인덱스를 지정한다.

layout(location = 0) in vec3 position;

위 코드는 정점 속성 변수를 선언하는 문장이며, position이라는 이름의 vec3 타입 변수가 셰이더 인풋(in) 변수이며 레이아웃은 0번 인덱스로 한정시키라는 의미를 가진다.


2. Per-link specification

layout qualifier가 복잡해보인다면 셰이더 링크 단계에서 정점 속성의 위치(location)을 바인딩시킬 수 있다. 정점 셰이더에서는

in vec3 position;

위와같이 정점 셰이더의 입력변수로 선언만 한 뒤, glLinkProgram을 호출하기 전에 glBindAttribLocation​를 사용하여 해당 변수의 인덱스를 지정할 수 있다.

// void glBindAttribLocation​(GLuint program​, GLuint index​, const GLchar *name​);
glBindAttribLocation(gShaderProgram, 0, "position");
glLinkProgram(gShaderProgram);

3. Automatic assignment

사실 셰이더 프로그램이 링크될 때 각 셰이더 변수의 인덱스는 자동 할당된다. 그러므로 링크 후 해당 변수의 인덱스를 가져와 해당 인덱스를 활성화 시켜주면 된다.

GLuint location = glGetAttribLocation(gShaderProgram, "position");
glEnableVertexAttribArray(location);

편해보이지만 셰이더가 링크될 때 셰이더 변수가 몇 번째 인덱스에 할당되는지 알 수 없어서 셰이더 프로그램을 일반화하기 어려워진다는 단점이 있다.


세 방식을 혼용해도 상관 없지만 동일 변수에 대한 명세는 열거된 순서대로 우선순위가 적용되며 혼란의 요지가 되므로 일반적으로는 맘에드는 한 가지 방식만을 사용한다. 예제에서는 in-shader specification 방식을 사용하여 0번 인덱스에 위치속성을 할당하였다.


정점 속성의 정의는 꽤 복잡하지만 어떻게 하면 좀 더 유연하게 정점 데이터를 다룰 수 있을까 하는 고민으로부터 만들어진 구조이므로 너무 불평하지는 말자.



정점 셰이더


다룰 대상인 정점 속성은 파악했으니 '어떻게'에 대해 볼 차례다. 본 예제에서는 복잡한거 다 걷어내고, 입력된 정점 속성을 다음 스테이지로 넘겨주는 작업만 한다.

#version 450

layout(location = 0) in vec3 position;

void main() {
	gl_Position = vec4(position, 1.f);
}

정점 셰이더의 전체적인 구조를 보자. 우선 제일 첫 줄에 #version 이라는 매크로가 붙어있다. 이는 셰이더 버전이 4.50 이므로 4.50 에 맞게 컴파일하라고 셰이더 컴파일러에게 알려주는것이다. 버전을 명시하지 않으면 이상한 버전으로 컴파일 될 수 있으니 명시하도록 하자.


다음으로 입력 변수인 정점 속성을 선언하고있다. CPU로부터 정점 데이터를 받을것인데, 각 정점의 위치 정보만 의미있게 쓰일것이다.


그 다음, 익숙한 main 함수가 보인다. GLSL은 어떤 셰이더든 void main() 함수에서 그 셰이더가 하는 일을 정의한다. 본 예제의 정점 셰이더에서는 gl_Position이라는 출력변수에 정점 위치 속성인 position을 4차원 벡터 형태로 대입한 후 작업을 마친다. 만약 정점의 위치를 변경하고 싶다면 position에 변환 행렬을 곱해줄 수 있을것이다.



프래그먼트 셰이더


프래그먼트 셰이더는 정점 셰이더로부터 출력된 정점 데이터가 래스터화 된 후 각 픽셀이 어떻게 처리될 것인지를 정의한다.

#version 450

out vec4 color;

void main() {
	color = vec4(0.8, 0.5, 0.5, 1.0);
}

정점 셰이더와 마찬가지로 버전을 명시해주었다. 그 다음 출력 변수로 vec4 타입의 color를 선언해 주었는데, 이는 프래그먼트 셰이더가 각 픽셀에 대해 연산한 후 뿜어내는 결과는 vec4타입의 데이터임을 나타낸다. 4차원 벡터인 이유는 RGBA 색상값이기 때문.


그리고 역시 main 함수를 정의하는것으로 프래그먼트 셰이더가 하는 일을 서술하는데, 지금은 복잡한 일 없이 출력변수에 고정된 색상값을 대입하고있다. 나중에 음영, 텍스쳐, 그림자등 고급스런 장면을 표현하기 위해서 프래그먼트 셰이더를 보다 심도있게 다룰것이다.



셰이더 프로그래밍을 하면서 주의할 점 하나는, GPU는 정점이든 픽셀이든 병렬적으로 처리하기 때문에 인접한 정점, 인접한 픽셀같은 개념이 없다는것이다. 지금이야 셰이더에서 별 일 안하니 뭐가 문제라는건지 잘 모르겠지만, 조금만 지나면 매우 성가신 제약이라는걸 느끼게 될 것이다.



그리기


셰이더까지 봤으니 '그려라' 라는 명령을 내려줘야 하지 않겠는가. 셰이더 프로그램 오브젝트도 만들고 정점 버퍼 오브젝트도 만들어뒀으니 적절한 API를 호출해주기만 하면 끝난다. 아예 렌더 함수를 통으로 보자.

void Render() {
	...

	// gShaderProgram의 셰이더 프로그램을 사용할것임
	glUseProgram(gShaderProgram);

	// 버퍼를 바인딩
	glBindBuffer(GL_ARRAY_BUFFER, gVertexBufferObject);

	//정점 속성을 활성화 및 정점 속성 형태 정의
	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 3, GL_FLOAT, 0, 0, NULL);

	// 그려라! 삼각형 프리미티브(primitive) 형태로 해석하며 0번 정점부터 그린다.
	glDrawArrays(GL_TRIANGLES, 0, gVertices.size());

	// 바인딩된 버퍼 해제
	glBindBuffer(GL_ARRAY_BUFFER, 0);
	// 활성화된 정점 속성 비활성화
	glDisableVertexAttribArray(0);

	// 셰이더 프로그램 사용 중지
	glUseProgram(0);
}

그리는 명령은 복잡하지 않다. 그리는 방식에 따라 API 차이가 조금씩 나지만, 전체적인 구조는 변하지 않는다.


초기화 - 사용할 프로그램을 지정하고, 버퍼를 활성화한다.

그리기 - 그리기 명령을 내린다. 여기서는 glDrawArrays.

마무리 - 활성화된 버퍼 및 정점 속성을 비활성화 하고 셰이더 프로그램의 사용을 중지한다.




이렇게 대강 셰이더 그리고 삼각형의 그리까지 봤는데, OpenGL 기초를 이해하는데 도움이 되었을지 모르겠다.


목표가 OpenGL을 배우는것인데 너무 삼천포로 빠질까 싶어 그래픽스 이론은 간략히 설명하려고 애쓰고있는 중인데, 사실 완전한 이해를 위해서는 API 몇개 보는것보다 그래픽스 이론을 확실히 이해하는것이 더 중요할지도 모르겠다. 만약 그래픽스 이론이 궁금하다면 관련 서적을 찾아보기를 추천한다. 아래는 개인적으로 추천하는 관련 서적들.


3D Graphics for Game Programming - 3D 그래픽스 기초를 이해하는데 좋다. 게임프로그래밍에 대한 내용은 거의 없는게 함정. 한글판도 있다.

Mathematics for Computer Graphics - 컴퓨터 그래픽스는 사실 굉장히 많은 수학이론에 바탕하는데, 관련 수학 이론을 공부하는데 도움이 된다.


책 뿐 아니라 OpenGL 명세를 살펴보는것도 강추. ..설명이 부실하니 제대로 설명할 생각은 안하고 책 추천만 하는것같다.


다음 강좌에서는 보다 많은 기능을 하는 새로운 예제와 함께 조금 더 깊숙히 OpenGL을 배워볼 예정이다.