[OpenGL ES를 이용한 3차원 컴퓨터 그래픽스 입문] 6. OpenGL ES와 쉐이더
OpenGL ES와 쉐이딩 언어의 관계, 정점 쉐이더 작성법, 애트리뷰트와 유니폼으로 데이터를 넘기는 방법과 드로우콜까지 정리했습니다.
개요
GPU 파이프라인에 입력되는 정점 배열은 정점 위치와 노멀 등의 데이터를 저장하는데, 정점 쉐이더는 한 번에 한 정점을 처리한다. GPU는 수많은 코어로 구성된 병렬 처리 구조를 가지고 있는데, 이들 코어는 다수의 정점을 동시에 처리하는 데 적합하다.
6.1 OpenGl ES와 쉐이딩 언어
OpenGL ES는 OpenGl의 부분집합이다. OpenGL ES 2.0은 OpenGL 2.0을 기반으로 정의되었고, 정점 쉐이더와 프래그먼트 쉐이더라는 두 가지 프로그램을 필요로 한다. OpenGL ES 3.0은 OpenGL 3.3을 기반으로 정의되었고, OpenGL ES 2.0에 다양한 기능을 추가했다. 이 책은 OpenGL ES 3.0에 초점을 둔다.
한편, OpenGL ES 3.1에는 범용적 GPU 연산을 위해 컴퓨트 쉐이더(compute shader)가 추가되었고, OpenGL ES 3.2에는 기하 쉐이더 (geometry shader)와 테썰레이션(tessellation) 기능이 추가되었다. 이 책은 컴퓨트 쉐이더와 기하 쉐이더를 다루지 않지만, 테썰레이션은 18장에서 다룬다.
OpenGL ES의 쉐이더는 GPU에 특화된 언어로 작성하는데, 이를 OpenGL ES Shading Language라고 한다. 앞으로 OpenGL ES는 GL로, OpenGL ES Shading Language는 GLSL로 간단히 표기할 것이다(OpenGL을 언급해야 할 때에는, 약칭 없이 그냥 OpenGL을 사용한다.) 이 책은 많은 GL 및 GLSL 예제를 제공하는데, 이 장은 그러한 예제를 이해하는 데 필요한 최소한의 GL 및 GLSL 기초를 다룬다. 본격적인 GL 및 GLSL 프로그래밍을 위해서는 참고문헌 4와 같은 매뉴얼을 참고하자.
6.2 정점 쉐이더
GLSL은 C와 비슷한 언어이다. 따라서, C에 익숙한 프로그래머는 GLSL의 데이터 타입, 연산, 제어구조 등을 쉽게 이해할 수 있다. 그러나 GPU는 CPU와 다른 목적 및 구조를 가진 프로세서이므로, GPU에서 작동하는 GLSL은 CPU에서 작동하는 C와 분명하게 구분되는 자신만의 특징을 가진다. 벡터와 행렬에 대한 데이터 타입이 대표적인 예가 될 것이다. GLSL은 float와 같은 기본 타입에 더불어 최대 4개 원소를 가지는 벡터 타입을 제공한다.
예를 들어 vec4는 4차원 실수 벡터를 ivec3은 3차원 정수 벡터를 의미한다. 또한 GLSL은 최대 4X4 크기를 가지는 행렬 타입을 제공한다. 예를 들어, mat3와 mat4는 각각 3X3과 4x4 크기의 정사각행렬을, mat3x4는 3X4 크기의 행렬을 의미한다. 행렬의 각 원소는 모두 float로 제한된다.
정점 쉐이더는 두 가지 종류의 입력을 받아들인다. 하나는 정점 배열에 저장된 정점별 애트리뷰트(attribute)들인데, 그림 6.1의 예에서 각 정점은 위치, 노멀, 텍스처 좌표(texCoord) 애트리뷰트를 가지고 있다(텍스처 좌표에 관해서는 8장에서 자세히 기술할 것이다). 한편, 예를 들어, 정점 배열이 1000개의 정점을 저장하고 있다면, 정점 쉐이더는 1000번 실행되는데, 1000번 실행되는 정점 쉐이더가 모두 공유하는 또 다른 종류의 입력 데이터가 있다. 대표적인 예가 월드/뷰/투영 변환인데, 이들은 모든 정점에 공히 적용된다. 이러한 입력 데이터를 유니폼(uniform)이라 부른다.
예제 코드 6-1은 아주 간단한 정점 쉐이더를 보여주고 있다. 1번 줄은 이 쉐이더가 GLSL 3.0으로 작성된 것을 말해 준다. 3번 줄에는 월드, 뷰, 투영 변환이 모두 4X4 크기 행렬로 정의되었는데, 이들은 모두 uniform이라는 키워드로 수식된다. 한편, 5~7번 줄에는 이 쉐이더에 입력되는 position, normal, texCoord 애트리뷰트가 in이라는 키워드로 수식되었다. 정점의 position과 normal은 모두 3차원 좌표를 가지므로, vec3로 정의되었다. 반면, texCoord는 vec2로 정의되었는데, 텍스처가 2차원 이미지 영상일 경우 여기에 접근하기 위한 좌표 역시 2차원으로 표시되기 때문이다.
애트리뷰트가 m개인 경우, 각각의 위치는 0, 1, 2, … , m - 1로 표기된다. 예제 코드 6-1에서는 layout이라는 키워드를 사용해 position, normal, texCoord의 위치를 각각 0, 1, 2로 지정하였다.
9번 줄과 10번 줄은 쉐이더가 출력할 v_normal과 v_texCoord를 정의하는데, 이들은 모두 out이라는 키워드로 수식된다. 이제 메인 함수를 보도록 하자. 이는 우선 오브젝트 공간에서 정의된 position을 클립 공간으로 변환한다. 이는 모든 정점 쉐이더가 반드시 수행해야 하는 것으로, 그 결과는 gl_Position이라는 내장 변수에 저장해야 한다. 한편, 세 가지 변환 행렬의 크기는 모두 4X4인데, position은 3차원 벡터이므로, 이를 4차원 벡터로 변환하기 위하여 vec4를 생성자로 사용하였다.
메인 함수의 두 번째 줄은 오브젝트 공간에서 정의된 normal을 월드 공간으로 변환하는데, mat3(worldMat)은 월드 행렬의 왼쪽 위 3X3 부분 행렬을 말한다. 5.1 절에서 기술한 바와 같이, 이는 [L|t]로 표기된 월드 행렬 중 L을 의미한다. L의 역전치행렬 $(L^{-1})^T$가 normal에 곱해지고 정규화되어 출력 변수인 v_normal에 저장된다. 여기에 사용된 inverse, transpose, normalize는 GLSL이 제공하는 라이브러리 함수들이다. 한편, 메인 함수의 마지막 줄은 texCoord를 v_texCoord에 그대로 복사하는데, 총 세 개의 출력 값(gl_Position, v_normal, v_texCoord)이 래스터라이저에게 전달될 것이다.
6.3 쉐이더를 위한 OpenGL ES 작업
앞 절에서 정점 쉐이더의 예를 살펴봤는데, 프래그먼트 쉐이더도 이와 비슷한 방식으로 작성된다(프래그먼트 쉐이더는 일반적으로 정점 쉐이더보다 길고 복잡한데, 이는 8장부터 기술될 것이다). 정점 및 프래그먼트 쉐이더가 렌더링을 위한 세부 작업을 수행한다면, GL 프로그램은 이들 쉐이더를 관리함과 동시에 쉐이더에 필요한 데이터를 공급하는 역할을 한다. GL 프로그램의 함수는 gl로, 데이터는 GL로 시작한다.
예제 코드 6-2. 쉐이더 오브젝트를 위한 GL 프로그램
예제 코드 6-2는 정점 쉐이더를 관리하는 GL 프로그램의 일부이다.
쉐이더 소스코드를 저장할 쉐이더 오브젝트(shader object)는 glCreateShader가 생성하는데, 이는 GL_VERTEX_SHADER 혹은 GL_FRAGMENT_SHADER를 입력 받아서 쉐이더 오브젝트의 ID를 리턴한다. 예제 코드 6-2에서는 정점 쉐이더 오브젝트 ID가 shader에 저장되었다.
GL 프로그램이 쉐이더 소스코드를 로드했고, char* source가 이 소스코드를 가리킨다고 가정하자. 이 소스코드는 glShaderSource에 의해 쉐이더 오브젝트에 저장된다.
쉐이더 오브젝트는 glCompileShader에 의해 컴파일된다.
예제 코드 6-3. 프로그램 오브젝트를 위한 GL 프로그램
정점 및 프래그먼트 쉐이더 오브젝트는 모두 프로그램 오브젝트에 붙여(attach)져야 한다. 그 뒤에 프로그램 오브젝트는 실행 파일로 링크된다. 예제 코드 6-3을 설명하면 다음과 같다.
- 프로그램 오브젝트가 glCreateProgram에 의해 생성되었다.
- shader를 ID로 가지는 정점 쉐이더 오브젝트가 glAttachShader에 의해 프로그램 오브젝트에 붙여졌다(프래그먼트 쉐이더 오브젝트도 동일한 방식으로 프로그램 오브젝트에 붙여져야 한다).
- 프로그램 오브젝트가 glLinkProgram에 의해 링크되었다.
- 프로그램 오브젝트를 렌더링에 사용하기 위해서 glUseProgram이 호출되었다.
6.4 애트리뷰트와 유니폼
GL 프로그램의 중요한 역할 중 하나는 정점 쉐이더가 애트리뷰트와 유니폼을 사용할 수 있도록 하는 것이다. 즉, GL 프로그램은 이들을 정점 쉐이더에게 건네줘야 하고, 또한 이들이 어떤 구조를 가지는지 정점 쉐이더에게 알려줘야 한다.
6.4.1 애트리뷰트와 버퍼 오브젝트
예제 코드 6-4. 정점 및 인덱스 배열 처리를 위한 GL 프로그램
예제 코드 6-4에서 Vertex 구조체는 정점의 위치(pos), 노멀(nor), 텍스처 좌표(tex)를 가지는데, 여기서 사용된 glm은 OpenGL 수학 라이브러리를 말하는 것으로, GLSL과 동일한 이름과 기능을 가진 클래스와 함수를 제공한다. 3.3절에서 기술한 바와 같이, .obj 파일 등에 저장된 폴리곤 메시 데이터가 GL 프로그램의 정점 및 인덱스 배열로 로드되었고, 이들을 각각 vertices와 indices가 가리킨다고 가정하자. 예제 코드 6-4에서 이들은 objData에 저장되었다.
그림 6.2. CPU 메모리의 정점 및 인덱스 배열과 GPU 메모리의 버퍼 오브젝트
현재 CPU 메모리에 저장되어 있는 정점 및 인덱스 배열은 렌더링을 위해서 GPU 메모리의 버퍼 오브젝트(buffer object)로 옮겨지는데, 정점 배열은 배열 버퍼 오브젝트(array buffer object)로, 인덱스 배열은 요소 배열 버퍼 오브젝트(element array buffer object)로 복사된다. 그림 6.2에 보인 것처럼 이들은 각각 GL_ARRAY_BUFFER와 GL_ELEMENT_ARRAY_BUFFER라고 부른다.
예제 코드 6-5. 배열 버퍼 오브젝트를 위한 GL 프로그램
예제 코드는 정점 배열을 위한 버퍼 오브젝트를 생성하고 채우는 과정을 보여준다.
- glGenBuffers(GLsizei n, GLuint* buffers)를 호출하여 버퍼 오브젝트를 생성한다. glGenBuffers는 n개의 버퍼 오브젝트를 buffers에 저장한다.
- 이 버퍼 오브젝트를 정점 배열에 바인드(bind)하기 위하여 GL_ARRAY_BUFFER를 파라미터로 glBindBuffer를 호출한다.
- 예제 코드 6-4에서 정의한 objData.vertices로 버퍼 오브젝트를 채우기 위하여 glBufferData를 호출한다. glBufferData의 두 번째 파라미터는 정점 배열의 크기를 지정하고, 세 번째 파라미터는 정점 배열을 가리킨다.
예제 코드 6-6. 요소 배열 버퍼 오브젝트를 위한 GL 프로그램
예제 코드 6-7. 정점 애트리뷰트를 위한 GL 프로그램
그림 6.3. GPU 버퍼 오브젝트에 저장된 정점 애트리뷰트
예제 코드 6.6에 보인 것처럼, 인덱스 배열도 마찬가지 방식으로 처리되는데, glBindBuffer와 glBufferData를 호출할 때 GL_ELEMENT_ARRAY_BUFFER를 사용한다. 이렇게 해서 정점 및 인덱스 배열이 모두 GPU 메모리로 옮겨졌는데, 그림 6.3은 pos, nor, tex라는 정점 애트리뷰트들이 배열 버퍼 오브젝트에 어떤 방식으로 저장되어 있는지 보여준다. 예제 코드 6-4에서 Vertex의 크기가 32바이트였으므로, 첫 번째 pos와 두 번째 pos 간 간격은 32바이트이다. 이런 바이트 간격을 스트라이드(stride; 보폭)라 한다. 하나의 pos를 얻은 후 동일한 스트라이드를 반복하여 앞으로 나가면 모든 pos를 얻을 수 있다. 이는 nor과 tex에서도 마찬가지이다.
GL 프로그램은 정점 쉐이더에게 그림 6.3과 같은 구조와 스트라이드를 알려줘야 한다. 예제 코드 6-1의 정점 쉐이더는 정점의 position 애트리뷰트 위치를 0으로 지정했었다. 예제 코드 6-7의 1번 줄은 glEnableVertexAttribArray를 이용하여 0번에 놓인 position을 활성화시킨다. 2번 줄은 glVertexAttribPointer를 이용하여 position에 대한 자세한 정보를 제공한다. 즉, 두 번째와 세 번쨰 파라미터는 position이 GL_FLOAT 3개로 정의되어 있음을, 다섯 번째 파라미터는 position의 스트라이드가 Vertex 크기인 32임을 알려준다. 또한, 마지막 파라미터는 버퍼 오브젝트에서 position이 나오는 처음 위치를 알려준다.
예제 코드 6-1의 정점 쉐이더는 정점의 normal과 texCoord 애트리뷰트 위치를 각각 1과 2로 지정했었다. 예제 코드 6-7은 position과 마찬가지 방식으로 이들 애트리뷰트를 활성화시킨 후 그 정보를 정점 쉐이더에게 제공한다. normal과 texCoord도 동일한 스트라이드 32를 가지는 것을 확인하자.
6.4.2 유니폼
예제 코드 6-8. 월드 행렬을 제공하는 GL 프로그램
예제 코드 6-1의 정점 쉐이더는 worldMat, viewMat, projMat 세 개의 유니폼을 사용하는데, 이 역시 GL 프로그램이 제공해야 한다. 예제 코드 6-8은 worldMat의 경우를 보여준다. 가상 공간에서 물체가 움직이는 경우 월드 행렬은 매 프레임 갱신되어야 하는데 이 행렬 이름이 worldMatrix라고 가정하자. 이는 예제 코드 6-8 첫 줄에 정의되어 있다. 이 worldMatrix를 정점 쉐이더 유니폼인 worldMat에 할당해야 하는데, 이를 위해서 GL 프로그램은 우선 worldMat의 위치를 알아내야 한다. 예제 코드 6-3에서 프로그램 오브젝트(program)를 링크했을 때 worldMat의 위치가 결정되었는데, 이 위치를 알아내기 위해 예제 코드 6-8의 GL 프로그램은 glGetUniformLocation을 호출한다.
한편, 정점 쉐이더의 유니폼 변수에 특정 값을 할당하기 위하여 GL은 glUniform으로 시작하는 함수들을 이용하는데, 4X4 행렬인 worldMat을 위해 glUniformMatrix4fv를 호출한다. 예제 코드 6-8에서 이 함수의 첫 파라미터는, glGetUniformLocation을 사용해 얻어진 loc, 즉 worldMat의 위치이다.
6.5 드로우콜
이제까지 소개한 바와 같은 다양한 함수를 이용해 정점 쉐이더가 필요로 하는 애트리뷰트와 유니폼을 GL 프로그램이 모두 제공했다면, 이제 해당 폴리곤 메시를 그리는 명령을 내려야 한다. 이를 드로우콜(drawcall)이라 한다. 그림 3.9에 주어진 메시를 예로 들어 보자. 총 48개의 삼각형을 가진 이 메시의 인덱스 배열은 144(48X3)개의 원소를 가지게 되는데, 이를 그리기 위해서는 glDrawElements(GL_TRIANGLES, 144, GL_UNSIGNED_SHORT, 0)을 호출한다. 만약, 이 메시가 인덱스 없이 표현되었다면, 즉 정점 배열로만 정의되었다면, glDrawArrays(GL_TRIANGLES, 0, 144)을 호출한다.
참고



