Notice
Recent Posts
Recent Comments
Link
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

게임공장

[Learn OpenGL 번역] 2-4. 시작하기 - Hello Triangle 본문

OpenGL

[Learn OpenGL 번역] 2-4. 시작하기 - Hello Triangle

짱승_ 2018. 7. 15. 19:56

Hello Triangle

시작하기/Hello Triangle

  OpenGL에서 모든 것은 3D 공간 안에 있습니다. 하지만 화면과 윈도우 창은 2차원 픽셀 배열입니다. 그렇기에 OpenGL 작업의 큰 부분을 차지하는 것은 모든 3D 좌표를 화면에 맞게 2D 픽셀로 변환하는 작업입니다. 3D 좌표를 2D 좌표로 변환하는 작업은 OpenGL의 그래픽 파이프라인(graphics pipeline) 에 의해 관리됩니다. 그래픽 파이프라인은 크게 두 개의 부분으로 나뉠 수 있습니다: 하나는 3D 좌표를 2D 좌표로 변환하는 것이고 다른 하나는 2D 좌표를 실제 색이 들어간 픽셀로 변환하는 것입니다. 이 강좌에서는 그래픽 파이프라인과 멋진 픽셀을 만들기 위해 그래픽 파이프라인을 어떻게 사용해야 하는지에 대해 간단히 다루도록 하겠습니다.

  2D 좌표와 픽셀은 차이점이 있습니다. 2D 좌표는 2D 공간에 있는 한점의 위치를 매우 정확하게 나타냅니다. 하지만 2D 픽셀은 여러분의 화면이나 윈도우 창의 해상도에 의해 제환되는 한 점의 근사치입니다.

  그래픽 파이프라인은 입력으로 3D 좌표를 받고 이 것을 여러분의 화면에 색이 입혀진 2D 픽셀로 변환합니다. 그래픽 파이프라인은 여러 단계로 나누어 집니다. 각 단계는 전 단계의 결과를 입력으로 받게 됩니다. 모든 단계는 매우 특성화(그들은 하나의 특정한 기능을 가지고 있습니다)되어 있습니다. 그리고 쉽게 병렬로 실행될 수 있습니다. 병렬성으로 인해 최근에 나온 대부분의 그래픽 카드가 그래픽 파이프라인의 각 단계에서 GPU 위에 작은 프로그램들을 실행시킴으로써 데이터를 빠르게 처리하기 위해 수천개의 작은 프로세싱 코어를 가지고 있습니다. 이 작은 프로그램들이라는 것은 shaders라고 불립니다.


  이 쉐이더 중 일부는 이미 존재하는 기본 쉐이더를 대체하기 위해 우리 자신만의 쉐이더를 작성함으로써 개발자에 의해 설정가능한 쉐이더도 있습니다. 이는 파이프라인의 특정한 부분을 좀 더 세밀한 조작을 가능하게 합니다. 그리고 쉐이더는 GPU 위에서 실행되기 때문에 귀중한 CPU 시간을 절약할 수 있습니다. 쉐이더는 OpenGL Shading Language (GLSL) 로 작성되고 우리는 다음 강좌에서 다룰 예정입니다.


  아래 그림에서 그래픽 파이프라인의 모든 단계가 추상화된 모형을 볼 수 있습니다. 파란색을 칠해진 부분이 우리가 작성한 쉐이더를 주입할 수 있는 부분입니다.

The OpenGL graphics pipeline with shader stages

  보시다시피 그래픽 파이프라인은 많은 부분들을 포함하고 있습니다. 이 부분들은 정점 데이터를 완전히 렌더링된 픽셀로 변환하는 데에 각자 하나의 특정 부분을 관리합니다. 여러분에게 파이프라인이 어떻게 동작하는지에 대해 이해하기 쉽도록 파이프라인의 각 부분에 대해서 간단히 설명드리도록 하겠습니다.


  그래픽 파이프라인의 입력으로서 삼각형을 구성할 수 있는 정점 데이터라 불리는 3개의 3D 좌표 리스트를 전달할 수 있습니다. 이 정점 데이터는 정점들의 집합입니다. 정점(vertex)은 기본적으로 3D 좌표에 대한 데이터의 집합입니다. 이 정점의 데이터는 정점 속성(vertex attributes)을 사용하여 나타낼 수 있습니다. 이 vertex 속성은 모든 데이터를 포함할 수 있지만 간단하게 하기 위해 각 정점은 3D 위치와 컬러 값을 가진다고 가정하겠습니다.

  OpenGL이 좌표값과 컬러 값의 집합을 만들기 위해 무엇을 해야하는지 알아야 하기 때문에 OpenGL에게 데이터를 사용하여 어떠한 종류의 렌더링 유형을 만 것인지 알려주어야 합니다. 데이터를 여러 점, 여러 삼각형, 하나의 긴 선으로 그리기를 원하시나요? 이러한 힌트들은 primitives라고 불립니다. 모든 그리기 명령을 호출할 때마다 OpenGL에게 주어지게 됩니다. 이러한 힌트들에는 GL_POINTS, GL_TRIANGLES, GL_LINE_STRIP가 있습니다.

  파이프라인의 첫번째 부분은 하나의 정점을 입력으로 받는 정점 쉐이더(vertex shader)입니다. vertex 쉐이더의 주 목적은 3D 좌표를 다른 3D 좌표(나중에 설명)로 변환하는 것입니다. 그리고 또한 vertex 속성에 대한 기본적인 처리를 할 수 있도록 합니다.


  primitve 조립(primitive assembly) 단계는 primitive 를 구성하고 primitive 도형의 모든 점들을 조립하는 vertex 쉐이더로부터 입력 값으로 모든 정점들(이나 하나의 정점 만약 GL_POINTS를 선택했다면)을 받습니다. 이 경우에는 삼각형입니다.


  primitive assembly 단계의 결과값은 geometry shader로 전달됩니다. geometry shader는 입력값으로 정점들의 집합을 받습니다. 이 정점들의 집합은 primitive를 구성하고 새로운 정점을 방출하여 새로운(또는 다른) primitive를 형성함으로써 다른 도형으로 변환될 수 있는 정점들입니다. 이 예제에서는 주어진 도형에서 두 번째 삼각형을 생성합니다.


  geometry shader의 출력값은 rasterization stage로 넘어가게 됩니다. raterization stage는 결과 primitive(s)를 최종 화면의 적절한 픽셀과 매핑합니다. 그 결과 fragment shader에서 사용할 fragment(조각)가 도출됩니다. fragment shader를 실행하기 전에 clipping이 수행됩니다. Clipping은 성능을 증가시키기 위해 여러분의 뷰 밖에 있는 모든 fragment들을 폐기합니다.

  OpenGL에서 fragment는 하나의 픽셀을 렌더링하기 위해 필요한 모든 데이터이다.

  fragment shader의 주 목적은 픽셀의 최종 컬러를 계산하는 것입니다. 그리고 이 단계는 일반적으로 OpenGL의 모든 고급 효과들이 발생하는 단계입니다. 일반적으로 fragment shader는 3D scene에 대한 데이터를 가지고 있습니다. 이 데이터는 최종 픽셀 컬러(광원, 그림자, 빛의 색 등)를 계산하기위해 사용될 수 있습니다.


  모든 해당 컬럼 값들이 결정된 후에 최종 결과물은 하나의 단계를 더 거치게 됩니다. 이 단계는 alpha testblending 단계라고 불립니다. 이 단계에서는 fragment의 해당 깊이(와 스텐실) 값(나중에 설명)을 체크 합니다. 그리고 최종 fragment가 다른 오브젝트보다 앞에 있는지 뒤에 있는지 체크하고 다른 오브젝트보다 뒤에 있는 fragment는 즉시 폐기됩니다. 또한 이 단계에서 alpha값(오브젝트의 투명도)을 확인하고 그에 맞춰 다른 오브젝트와 blend(섞는다)합니다. 그래서 fragment shader에서 픽셀 출력 색이 계산되었더라도 최종 픽셀 컬러는 여러개의 삼각형을 렌더링할 때 완전히 다른 색이 될 수 있습니다.


  보시다시피 그래픽 파이프라인은 꽤 복잡하고 많은 설정가능한 부분들을 포함하고 있습니다. 하지만 대부분의 예제에서 우리는 vertex shader와 fragment shader만 사용할 것입니다. Geometry shader는 선택적으로 사용하고 일반적으로 기본값으로 남겨놓습니다.


  현대 OpenGL에서 최소한 vertex shader와 fragment shader는 우리 스스로 작성한 것을 사용하기를 요구합니다(GPU에 기본 vertex/fragment shader가 존재하지 않습니다). 이러한 이유로 첫 번째 삼각형을 렌더링 하기 전에 많은 지식을 요구하기 때문에 현대 OpenGL 공부를 시작하기 꽤 어렵습니다. 이 장의 마지막에 최종적으로 여러분의 삼각형을 한번 렌더링 해본다면 결국 그래픽 프로그래밍에 대해서 많은 것을 알게 될 것입니다.

Vertex 입력

  무언가를 그리는 것을 시작하기 위해 우리는 먼저 OpenGL에게 입력 vertext 데이터를 주어야합니다. OpenGL은 3D 그래픽 라이브러리이기 때문에 우리가 명시한 모든 좌표는 3D 공간의 좌표입니다(x, y, z 좌표). OpenGL은 모든 3D 좌표를 화면상의 2D 픽셀로 간단히 변환하는게 아닙니다. OpenGL은 3D 좌표가 모든 3개의 축(x, y, z)에서 값이 모두 -1.01.0 사이에 있어야만 처리하게 됩니다. 소위 normalized device coordinates 범위라고 불리는 범위 안에 있는 모든 좌표들은 최종적으로 여러분의 화면에 보이게 됩니다(이 범위 밖에 있는 모든 좌표들은 보이지 않습니다).


  우리는 하나의 삼각형을 렌더링하기 원하기 때문에 우리는 3D 위치를 가지고 있는 3개의 정점들을 명시해야 합니다. 우리는 그것들을 앞서 말한 normailzed device doordinates(OpenGL의 보일 수 있는 영역) 범위 안에 float 배열로 정의해야 합니다.


float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};  

  OpenGL은 3D 공간에서 동작하기 때문에 우리는 z 좌표가 0.0인 정점들을 이용해 2D 삼각형을 그립니다. 이 방법은 2D처럼 보이도록 삼각형의 깊이(depth)를 모두 같게 만듭니다.

Normalized Device Coordinates (NDC)

  vertex shader에서 여러분의 정점 좌표들이 처리가 되면 정점들이 normalized device coordinates 내부에 들어가게 됩니다. normailized device coordinatesx, y, z 값이 모두 -1.01.0 사이에 있는 작은 공간입니다. 이 영역 밖에있는 모든 좌표들은 폐기되고 여러분의 화면에 보이지 않게 됩니다. 아래에서 normalized device coordinates에 명시된 삼각형을 볼 수 있습니다(z 축은 무시)


2D Normalized Device Coordinates as shown in a graph


  일반적인 화면 좌표와 달리 y축이 양수인 곳은 화면의 위쪽을 향합니다. 그리고 (0, 0) 좌표는 좌측 상단이 아니라 화면의 중앙을 나타냅니다. 결국 모든(변환된) 좌표들이 이 영역 내부에 있어야 합니다. 그렇지 않으면 보이지 않습니다.


  그런 다음 NDC 좌표는 glViewport 함수에 제공한 데이터를 사용하여 viewport transform을 통해 screen-space coordinates 좌표로 변환됩니다. 최종 screen-space coordinates 좌표는 fragment로 변환되어 fragment shader의 입력값이 됩니다.

  정점 데이터가 정의되면 우리는 그 데이터를 그래픽 파이프라인의 첫 번째 단계인 vertex shader에게 전달합니다. GPU에 정점 데이터를 저장할 공간의 메모리를 할당하고 OpenGL이 어떻게 메모리를 해석할 것인지 구성하고 데이터를 어떻게 그래픽 카드에 전달할 것인지에 대해 명시함으로써 이 작업이 완료됩니다. 그런 다음 vertex shader는 우리가 말한 만큼의 정점들을 메모리에서 처리합니다.


  우리는 vertex buffer objects(VBO)라고 불리는 것을 통해 이 메모리를 관리합니다. VBO는 많은 양의 정점들을 GPU 메모리 상에 저장할 수 있습니다. 이러한 버퍼 객체를 사용하면 한 번에 정점 데이터를 보내지 않아도 대량의 데이터를 한꺼번에 그래픽 카드로 전송할 수 있다는 장점이 있습니다. CPU에서 그래픽 카드로 데이터를 전송하는 것은 비교적 느립니다. 그래서 가능한 많은 데이터를 한 번에 보내야 합니다. 데이터가 그래픽 카드의 메모리에 할당되기만하면 vertex shader는 거의 즉각적으로 빠르게 정점들에 접근할 수 있습니다.


  vertex buffer object는 우리가 사용할 첫 번째 OpenGL 객체입니다. OpenGL의 모든 객체처럼 이 버퍼도 버퍼에 맞는 고유한 ID를 가지고 있습니다. 그래서 우리는 glGenBuffers 함수를 사용하여 버퍼 ID를 생성할 수 있습니다.


unsigned int VBO;
glGenBuffers(1, &VBO);  

  OpenGL은 많은 유형의 버퍼 객체를 가지고 있으며 vertex buffer object의 버퍼 유형은 GL_ARRAY_BUFFER입니다. OpenGL은 버퍼 유형이 다른 여러가지 버퍼를 바인딩할 수 있습니다. 우리는 새롭게 생성된 버퍼를 glBindBuffer 함수를 사용하여 GL_ARRAY_BUFFER로 바인딩할 수 있습니다.


glBindBuffer(GL_ARRAY_BUFFER, VBO);  

  그 시점부터 우리가 호출하는 모든 버퍼(GL_ARRAY_BUFFER를 타겟으로 하는)는 현재 바인딩 된 버퍼(VBO)를 사용하게 됩니다. 그런 다음 우리는 glBufferData 함수를 호출할 수 있습니다. 이 함수는 미리 정의된 정점 데이터를 버퍼의 메모리에 복사합니다.


glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

  glBufferData 함수는 사용자가 정의한 데이터를 현재 바인딩된 버퍼에 복사하는 기능을 수행합니다. 첫 번째 파라미터는 우리가 데이터를 복사하여 집어넣을 버퍼의 유형(현재 바인딩된 vertex buffer object GL_ARRAY_BUFFER)을 받습니다. 두 번째 파라미터는 버퍼에 저장할 데이터의 크기(바이트 단위)를 받습니다. 간단히 sizeof 키워드를 써도 충분합니다. 세 번째 파라미터는 우리가 보낼 실제 데이터를 받습니다.


  네 번째 파라미터는 그래픽 카드가 주어진 데이터를 관리하는 방법을 받습니다. 이 것은 3가지의 종류가 있습니다.


  • GL_STATIC_DRAW: 데이터가 거의 변하지 않습니다.
  • GL_DYNAMIC_DRAW: 데이터가 자주 변경됩니다.
  • GL_STREAM_DRAW: 데이터가 그려질때마다 변경됩니다.

  삼각형의 위치 데이터는 모든 렌더링 호출 때마다 변하지 않고 항상 같으므로 GL_STATIC_DRAW가 가장 알맞습니다. 예를 들어 자주 바뀔 수 있는 데이터가 들어있는 버퍼일 경우 GL_DYNAMIC_DRAW, GL_STREAM_DRAW로 설정하면 그래픽 카드가 빠르게 쓸 수 있는 메모리에 데이터를 저장합니다.


  현재로서는 정점 데이터를 그래픽 카드의 메모리에 저장했습니다. 이 메모리는 VBO라고도 불리는 vertex buffer object가 관리하게 됩니다. 다음에 우리는 실제적으로 이 데이터를 처리하는 vertex shader와 fragment shader를 만들어 볼 것입니다.

Vertex shader

  Vertex shader는 우리가 프로그래밍할 수 있는 shader 중 하나입니다. 현대 OpenGL에서는 무엇인가를 렌더링 하기 위해서 최소한 vertex shader와 fragment shader는 세팅 하기를 요구합니다. 그래서 우리의 첫 번째 삼각형을 그리기 위한 shader에 대해 간단히 살명하고 2개의 아주 간단한 shader를 구성해볼 예정입니다. 다음 장에서는 shader에 대해서 좀 더 상세히 다루도록 하겠습니다.


  먼저 우리가 해야할 일은 shader 언어인 GLSL(OpenGL Shading Language)를 통해 vertex shader를 작성하고 컴파일하여 우리의 응용 프로그램에서 사용하는 것입니다. 아래에서 GLSL로 작성된 아주 기초적인 vertex shader의 코드를 볼 수 있습니다.


#version 330 core
layout (location = 0) in vec3 aPos;

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

  보시다시피 GLSL은 C와 비슷하게 보입니다. 각 shader는 버전을 선언하는 것부터 시작합니다. OpenGL 3.3 이상 버전부터 GLSL 버전은 OpenGL 버전과 맞아야 합니다(예를 들어 GLSL 420버전은 OpenGL 4.2버전과 맞습니다). 또한 우리가 core profile 기능을 사용할 것이라고 명시해주어야 합니다.


  그런 다음 vertex shader에 in 키워드를 사용하여 모든 입력 정점 속성을 선언해야 합니다. 현재는 위치 데이터만 사용하므로 오직 하나의 정점 속성만 필요합니다. GLSL은 vector 데이터타입을 가지고 있습니다. vector는 1~4개의 실수로 구성되어집니다. 각 정점은 3D 좌표를 가지고 있기 때문에 우리는 aPos라는 이름을 가진 vec3 타입 입력 변수를 생성합니다. 또한 layout (location = 0) 코드를 통해 입력 변수의 location을 설정합니다. 왜 location이 필요한지는 나중에 설명드리도록 하겠습니다.

Vector
  그래픽 프로그래밍에서 수학적인 개념을 상당히 자주 사용합니다. 어떠한 공간에서의 위치/방향을 깔끔하게 표현하고 유용한 수학적 특성들을 가지고 있기 때문입니다. GLSL의 벡터는 최대 크기가 4이고 각 값은 vec.c, vec.y, vec.z를 통해 접근할 수 있습니다. 여기서 각 값들은 공간에서의 좌표를 나타냅니다. vec.w는 공간에서 위치를 나타내지 않고(우리는 4D가 아니라 3D를 사용합니다) perspective division 라고 불리는 것에 쓰입니다. 나중에 벡터에 대해서 좀 더 자세히 다룰 것입니다.

  Vertex shader의 출력값을 설정하기 위해 미리 선언된 gl_Position 변수에 위치 데이터를 할당해야 합니다. 이 변수는 vec4 타입의 변수입니다. main 함수의 끝에 gl_Position에 설정한 것을 vertex shader의 출력값으로 사용할 것입니다. 우리의 입력값은 크기가 3인 vector이기 때문에 이 것을 크기가 4인 vector로 형변환해주어야 합니다. 우리는 vec3 값을 vec4의 생성자 안에 추가하고 w 값을 1.0f으로 설정하여 형변환을 수행할 수 있습니다(그 이유는 나중에 설명하겠습니다).


  현재 vertex shader는 우리가 생각할 수 있는 가장 간단한 vertex shader입니다. 입력 데이터에 대해 아무런 처리도 하지않고 간단히 shader의 출력값으로 전달했기 때문입니다. 실제 응용 프로그램에서 입력 데이터는 일반적으로 normalized device coordinates 영역에 포함되지 않는 값들입니다. 그래서 우리는 먼저 입력 데이터를 OpenGL의 표시할 수 있는 영역 내에 있는 좌표로 변환해야 합니다.

Shader 컴파일

  Vertex shader의 소스 코드를 작성해 보았습니다. 하지만 OpenGL이 shader를 사용하기 위해서는 런타임시에 shader 소스 코드를 동적으로 컴파일해야 합니다.


  먼저 해야할 일은 ID를 참조하여 shader 객체를 생성하는 것입니다.그래서 우리는 vertex shader를 unsigned int 타입으로 저장해야 하고 glCreateShader 함수를 사용하여 shader를 생성해야 합니다.


unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

  glCreateShader 함수의 파라미터로 우리가 생성할 shader의 유형을 입력합니다. 우리는 vertex shader를 생성하기 때문에 GL_VERTEX_SHADER를 파라미터로 입력합니다.


  그런 다음 shader의 소스 코드를 shader 객체에 첨부한 후 shader를 컴파일합니다.


glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

  glShaderSource 함수는 shader를 컴파일하기 위해 shader 객체를 첫 번째 파라미터로 받습니다. 두 번째 파라미터는 소스 코드가 몇개의 문자열로 되어있는지에 대한 값을 받습니다(여기선 오직 하나입니다). 세 번째 파라미터는 vertex shader의 실제 소스코드를 받고 네 번째 파라미터는 NULL로 남겨두겠습니다.

  glCompileShader 함수를 호출한 후 컴파일이 성공적으로 완료되었는지 확인할 수 있습니다. 성공적으로 완료되지 않았다면 어떤 오류가 발생했는지 알 수 있으며 그 오류를 해결할 수 있습니다. 컴파일 에러를 확인하는 코드는 다음과 같습니다.


int  success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

  먼저 우리는 성공을 나타내는 정수형 변수를 선언하고 에러 메시지를 담을 컨테이너를 선언합니다(만약에 있다면). 그런 다음 glGetShaderiv 함수를 사용하여 컴파일이 성공했는지 확인합니다. 컴파일이 실패하였다면 glGetShaderInfoLog 함수를 통해 에러메시지를 확인해야 하고 에러메시지를 출력해야 합니다.


if(!success)
{
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

  컴파일 도중 에러가 발생하지 않았다면 vertex shader가 컴파일 완료된 것입니다.

Fragment shader

  Fragment shader 삼각형을 렌더링하기 위해 생성해야할 두 번째이자 마지막 쉐이더입니다. Fragment shader는 픽셀의 출력 컬러 값을 계산하는 것에 관한 쉐이더입니다. 간단하게 하기 위해서 frament shader는 항상 주황색을 출력하게 할 것입니다.

  컴퓨터 그래픽에서 컬러는 4개의 값(빨간색, 녹색, 파란색, 알파 4가지 일반적으로 RGBA로 축약)의 리스트로 나타내어집니다. OpenGL이나 GLSL에서 컬러를 정의할 때 각 값들의 채도를 0.01.0 사이의 값으로 설정합니다. 예를 들어 빨간색을 1.0f, 녹색을 1.0f로 설정하면 두 가지의 색을 혼합하여 노란색을 출력할 수 있습니다. 주어진 3가지의 색으로 1,600만개 이상의 다른 색을 만들 수 있습니다.

#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
} 

  Fragment shader는 오직 하나의 출력 변수를 필요로합니다. 그 변수는 크기가 4인 vector 이고 우리가 스스로 계산해야하는 최종 컬러를 정의합니다. out 키워드를 사용하여 출력 값을 선언할 수 있습니다. 여기서 FragColor 변수가 바로 그것입니다. 그런 다음 출력 컬러에 vec4 타입으로 컬러(주황색)를 간단히 할당합니다. 알파값은 1.0으로 설정합니다(1.0은 완전한 불투명입니다).


  Fragment shader 컴파일은 vertex shader와 비슷합니다. GL_FRAGMENT_SHADER 상수를 shader 타입으로 설정해주기만 하면 됩니다.


unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

  두 개의 shader는 지금 컴파일되었고 마지막 할일은 shader program으로 두 개의 shader 객체를 서로 연결하는 것입니다. shader program은 렌더링할 때 우리가 사용할 수 있습니다.

Shader program

  Shader program 객체는 여러 shader를 결합한 마지막 연결된 버전입니다. 컴파일된 shader들을 사용하기위해 shader들을 shader program 객체로 연결(link) 해주어야 합니다. 그런 다음 오브젝트를 렌더링할 때 이 shader program을 활성화시키면 됩니다. 활성화된 shader program 안의 shader들은 렌더링 명령이 호출될 때 사용됩니다.


  Shader들을 program에 연결할 때 각 shader들의 출력 값을 다음 shader의 입력값으로 연결합니다. 출력과 입력이 일치하지 않으면 연결 오류가 발생합니다.


  Program 객체를 생성하는 것은 쉽습니다.


unsigned int shaderProgram;
shaderProgram = glCreateProgram();

  glCreateProgram 함수는 program을 생성하고 생성된 program 객체의 ID를 리턴합니다. 이제 우리는 이전에 컴파일 했던 shader들을 program 객체에 첨부해야 합니다. 그런 다음 glLinkProgram 함수를 사용하여 그들을 연결시켜야합니다.


glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

  코드는 따로 설명이 필요가 없습니다. shader들을 program에 붙이고 glLinkProgram 함수를 사용하여 그들을 연결합니다.

  Shader를 컴파일할 때처럼 shader program을 연결 성공 여부를 확인할 수 있고 그에 해당하는 로그를 찾아볼 수 있습니다. 하지만 glGetShaderiv 함수와 glGetShaderInfoLog 함수를 사용하는 대신 아래와 같이 사용합니다.

glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    ...
}

  결과는 glUseProgram 함수를 호출하여 활성화할 수 있는 program 객체입니다. 이 program 객체는 이 함수의 파라미터로 들어가게 됩니다.


glUseProgram(shaderProgram);

  glUseProgram 함수를 호출한 이후의 모든 shader와 렌더링 명령은 이 program 객체(혹은 내부의 shader들)를 사용핳게 됩니다.


  Oh yeah, 그리고 shader들을 program 객체로 연결하고 나면 shader 객체들을 제거하는 것을 잊지마세요. 우리는 더 이상 필요하지 않습니다.


glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);  

  우리는 방금 입력 정점 데이터를 GPU로 보냈고 GPU에게 정점 데이터를 어떻게 처리해야 하는지 vertex shader와 fragment shader를 통해 지시하였습니다. 거의 다 왔습니다. 하지만 아직 아닙니다. OpenGL은 메모리 상의 정점 데이터를 어떻게 해석해야하는지 아직 모릅니다. 또한 정점 데이터를 vertex shader의 속성들과 어떻게 연결해야 하는지도 모릅니다. OpenGL에게 이러한 것들을 알려주는 게 좋을 것입니다.

정점 속성 연결(Linking Vertex Attributes)

  Vertex shader는 우리가 원하는 모든 입력들을 정점 속성의 형식으로 지정할 수 있도록 해줍니다. 또한 유연성이 좋은 반면에 입력 데이터의 어느 부분이 vertex shader의 어떠한 정점 속성과 맞는지 직접 지정해야 합니다. 이는 렌더링 하기 전에 OpenGL이 정점 데이터를 어떻게 해석해야하는지를 우리가 지정해주어야 한다는 뜻입니다.


  우리의 vertex buffer 데이터는 다음과 같은 형식을 취하고 있습니다.

Vertex attribte pointer setup of OpenGL VBO
  • 위치 데이터는 32-비트(4 바이트)의 실수형 점입니다.
  • 각 위치는 3가지의 값으로 구성되어있습니다.
  • 각 3개 값의 집합들 사이에 공백은 없습니다. 값들은 배열에 빽빽히 채워져(tightly packed)있습니다.
  • 데이터의 첫 번째 값은 버퍼의 시작 지점에 있습니다.

  glVertexAttribPointer함수를 사용하면 위의 사실들과 함께 OpenGL에게 vertex 데이터(vertex 속성에 대한)를 어떻게 해석해야 하는지를 알려줄 수 있습니다.


glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

  glVertexAttribPointer 함수는 꽤 많은 파라미터를 가지고 있습니다. 하나하나 알아보도록 하겠습니다.


  • 첫 번째 파라미터는 설정할 vertex 속성을 지정합니다. Vertex shader에서 layout (location = 0) 코드를 사용하여 position vertex 속성의 위치를 지정했었던 것을 기억하세요. 이는 vertex 속성의 위치(location)을 0으로 설정하고 우리는 데이터를 이 vertex 속성에 전달하고자 하기 때문에 0에 전달합니다.
  • 다음 파라미터는 vertex 속성의 크기를 지정합니다. 이 vertex 속성은 vec3 타입이므로 3개의 값으로 이루어져 있습니다.
  • 세 번째 파라미터는 데이터의 타입을 지정합니다. 여기서는 GL_FLOAT으로 지정합니다(GLSL에서 vec*은 실수형 점으로 이루어집니다).
  • 다음 파라미터는 데이터를 정규화할 것인지 지정합니다. 이 파라미터를 GL_TRUE로 설정하면 0(혹은 부호를 가진 데이터라면 -1)와 1 사이에 있지 않는 값들의 데이터들이 그 사이의 값들로 매핑됩니다. 우리는 GL_FALSE로 놔두겠습니다.
  • 다섯 번째 파라미터는 stride라고도 불리며 연이은 vertex 속성 세트들 사이의 공백을 알려줍니다. 다음 포지션 데이터의 세트는 정확히 float 타입 3개의 크기 뒤에 떨어져 있습니다. 우리는 이 값을 stride로 지정합니다. 이 배열이 빽빽히 채워져있다(다음 vertex 속성 값 사이에 공백이 없음)는 것을 알고 있다면 stride를 0으로 지정하여 OpenGL이 stride를 지정하게 할 수 있습니다(값들이 빽빽히 채워져 있을 때만 동작). 더 많은 vertex 속성들이 있다면 우리는 각 vertex 속성들 사이의 공간을 조심스럽게 정의해야 합니다. 우리는 나중에 이러한 상황의 예제를 살펴볼 것입니다.
  • 마지막 파라미터는 void* 타입이므로 형변환이 필요합니다. 이는 버퍼에서 데이터가 시작하는 위치의 offset입니다. 위치 데이터가 데이터 배열의 시작 부분에 있기 때문이 이 파라미터는 0으로 지정합니다. 우리는 이 파라미터에 대해 나중에 좀 더 자세히 살펴볼 것입니다.
  각 vertex 속성은 VBO에 의해 관리되는 메모리로부터 데이터를 받습니다. 그리고 데이터를 받을 VBO(하나가 여러 VBO를 가질수도 있습니다)는 glVertexAttribPointer 함수를 호출할 때 GL_ARRAY_BUFFER에 현재 바인딩된 VBO로 결정됩니다. glVertexAttribPointer 함수가 호출하기 전에 미리 정의된 VBO가 바인딩 되어 있으므로 vertex 속성 0이 해당 vertex 정점과 연결됩니다.

  이제 OpenGL이 vertex 데이터를 어떻게 해석해야하는지 지정했으므로 glEnableVertexAttribArray 함수의 파라미터로 vertex 속성 location를 전달하고 호출하여 vertex 속성을 사용할 수 있도록 해야 합니다. Vertex 속성은 기본적으로 사용하지 못하도록 설정되어 있습니다. 모두 설정이 된 후부터 vertex buffer 객체를 사용하여 vertex 데이터를 초기화하였고 vertex shader와 fragment shader를 셋업했습니다. 또한 OpenGL에게 vertex 데이터가 vertex shader의 vertex 속성에 어떻게 연결되는지 알려주었습니다. OpenGL의 오브젝트를 그리는 것은 다음과 같이 형식을 취합니다.


// 0. 정점 배열을 OpenGL에서 사용하기 위해 버퍼에 복사
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 그런 다음 vertex 속성 포인터를 설정
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
// 2. 오브젝트를 그리고 싶을 때 우리가 생성한 shader program을 사용
glUseProgram(shaderProgram);
// 3. 이제 오브젝트를 그립니다. 
someOpenGLFunctionThatDrawsOurTriangle();   

  오브젝트를 그려야할 때마다 우리는 이 과정을 반복해야 합니다. 그렇게 많아 보이지 않을 수 있지만 5개 이상의 vertex 속성과 100개의 다른 오브젝트들(흔한 경우입니다)이 있다고 생각해보세요. 오브젝트들에 대해 신속히 적절한 buffer 객체를 바인딩하는 것과 모든 vertex 속성들을 구성하는 것은 번거로운 과정이됩니다. 이 모든 상태 설정을 객체에 저장하고 간단히 이 객체를 바인딩하여 상태를 복원할 수 있는 방법이 있다면 어떨까요?

Vertex Array Object

  vertex array object (VAO라고도 불립니다) vertex buffer object와 같이 바인딩 될 수 있으며 그 이후의 vertex 속성 호출은 VAO 내에 저장됩니다. 이는 vertex 속성 포인터를 구성할 때 오직 한 번 호출하기만 하면 되고 오브젝트를 그려야 할 때마다 해당 VAO를 바인딩 하기만 하면 된다는 장점을 가지고 있습니다. 이는 서로 다른 vertex 데이터와 속성들을 다른 VAO를 바인딩함으로써 손쉽게 교체할 수 있습니다. 설정한 모든 상태가 VAO 내부에 저장됩니다.

  Core OpenGL은 정점 입력과 관련하여 VAO를 사용하도록 요구합니다. VAO를 바인딩하는데에 실패한다면 OpenGL은 어떤것이든 그리기를 거부할지도 모릅니다.

  Vertex array object는 다음 항목들을 저장합니다.


  • glEnableVertexAttribArray 함수나 glDisableVertexAttribArray 함수의 호출
  • glVertexAttribPointer 함수를 통한 Vertex 속성의 구성
  • glVertexAttribPointer 함수를 통해 vertex 속성과 연결된 Vertex buffer objects(VBOs)
Image of how a VAO (Vertex Array Object) operates and what it stores in OpenGL

  VAO를 생성하는 과정은 VBO와 비슷합니다.


unsigned int VAO;
glGenVertexArrays(1, &VAO);  

  VAO를 사용하기 위해 해야할 일은 glBindVertexArray 함수를 사용하여 VAO를 바인딩 하는 것입니다. 그 후부터 해당 VBO(s)와 속성 포인터를 바인딩/구성하고 VAO를 나중에 사용하기 위해 언바인딩해야 합니다. 오브젝트를 그리려면 그 전에 간단히 원하는 세팅과 함께 VAO를 바인딩하기만 하면 됩니다. 코드에서는 다음과 같이 보일 수 있습니다.


// ..:: 초기화 코드 (한번만 실행됩니다(오브젝트가 자주 변경되지 않는 한)) :: ..
// 1. Vertex Array Object 바인딩
glBindVertexArray(VAO);
// 2. OpenGL이 사용하기 위해 vertex 리스트를 복사
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 그런 다음 vertex 속성 포인터를 세팅
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

  
[...]

// ..:: 드로잉 코드 (렌더링 루프 내부) :: ..
// 4. 오브젝트를 그립니다.
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();   

  이게 다입니다! 지금까지 우리가 한 모든 일이 이 것을 위한 것이었습니다. VAO는 vertex 속성 구성을 저장합니다. 일반적으로 여러가지의 오브젝트들을 그리고 싶을 때 먼저 모든 VAO(또한 필요한 VBO와 속성 포인터들)를 생성하고 구성합니다. 그리고 그 것들을 나중에 사용하기 위해 저장합니다. 오브젝트들 중에 하나를 그리고 싶다면 해당 VAO를 가져와 바인딩하고 오브젝트를 그린 후 VAO를 다시 언바인딩합니다.

기다리고 기다리던 우리의 삼각형

  우리의 오브젝트를 그리기 위해서 OpenGL은 glDrawArrays 함수를 제공해줍니다. 이 함수는 현재 활성화된 shader, 이전에 정의된 vertex 속성 구성, VBO의 vertex 데이터(VAO를 통해 간접적으로 바인딩 된)를 사용하여 프리미티브를 그립니다.


glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);

  glDrawArrays 함수는 첫 번째 파라미터로 우리가 그리려 하는 OpenGL 프리미티브 유형을 지정합니다. 우리는 삼각형을 그리고자 하기 때문에 GL_TRIANGLES으로 지정합니다. 두 번째 파라미터는 vertex 배열의 시작 인덱스를 지정합니다. 우리는 0으로 지정합니다. 마지막 파라미터는 몇개의 vertex를 그리기 원하는지를 지정합니다. 우리의 경우에는 3(1개의 삼각형을 그리기 때문에 3개의 정점)입니다.


  이제 코드를 컴파일해봅시다. 컴파일이 완료 되면 다음과 같은 결과를 확인할 수 있을 것입니다.


An image of a basic triangle rendered in modern OpenGL

  완전한 프로그램의 전체 소스 코드는 여기에서 볼 수 있습니다 .


  이 화면과 같이 나오지 않는다면 아마 잘못된 방법을 사용했을 것이므로 소스 코드를 세세히 확인해보세요.

Element Buffer Objects

  정점들을 렌더링할 때 생각해야할 게 하나 더 있습니다. 그것은 element buffer objects입니다. EBO로 축약할 수 있습니다. element buffer objects가 어떻게 작동하는지 알려주기 위해 다음 예제를 드리겠습니다. 만약 삼각형 말고 사각형을 그려야한다고 가정해봅시다. 우리는 2개의 삼각형을 사용하여 사각형을 그릴 수 있습니다(OpenGL은 주로 삼각형으로 작동합니다). 이는 다음과 같은 정점들로 생성할 수 있습니다.

	
float vertices[] = {
    // 첫 번째 삼각형
     0.5f,  0.5f, 0.0f,  // 우측 상단
     0.5f, -0.5f, 0.0f,  // 우측 하단
    -0.5f,  0.5f, 0.0f,  // 좌측 상단 
    // second triangle
     0.5f, -0.5f, 0.0f,  // 우측 하단
    -0.5f, -0.5f, 0.0f,  // 좌측 하단
    -0.5f,  0.5f, 0.0f   // 좌측 상단
}; 

  보시다시피 정점을 명시하는데에 중복되는 것이 있습니다. 우측 하단와 <좌측 상단>이 2개씩 존재합니다. 동일한 사각형이 6개의 정점 대신 4개의 정점으로 생성될 수 있기 때문에 이는 50% 오버헤드가 발생한다고 생각할 수 있습니다. 1000개 이상의 삼각형들로 이루어진 복잡한 모델에서는 이는 더 심각해집니다. 오직 고유한 정점들만 저장하고 그들의 순서를 지정하는 좋은 방법은 없을까요? 이 경우에 사각형을 그리기 위해 4개의 정점만을 저장하면 됩니다. 그런 다음 그들을 그리는 순서를 지정하면 됩니다. OpenGL이 이러한 기능을 제공한다면 얼마나 좋을까요?


  고맙게도 element buffer objects는 정확이 이렇게 동작합니다. EBO는 VBO와 같은 버퍼입니다. EBO는 OpenGL이 어떠한 정점들을 그려야할 지 결정할 수 있는 인덱스들을 저장합니다. 이는 indexed drawing라고도 불리고 해당 문제에 대한 정확한 해결방법입니다. 시작하기 위해 우리는 먼저 (고유한) 정점들을 지정해야하고 사각형을 그릴 정점들의 인덱스를 지정해야합니다.


float vertices[] = {
     0.5f,  0.5f, 0.0f,  // 우측 상단
     0.5f, -0.5f, 0.0f,  // 우측 하단
    -0.5f, -0.5f, 0.0f,  // 좌측 하단
    -0.5f,  0.5f, 0.0f   // 좌측 상단
};
unsigned int indices[] = {  // 0부터 시작한다는 것을 명심하세요!
    0, 1, 3,   // 첫 번째 삼각형
    1, 2, 3    // 두 번째 삼각형
};  

  보시다시피 인덱스를 사용하면 6개가 아닌 오직 4개의 정점만이 필요합니다. 그런 다음 element buffer object를 생성해야 합니다.


unsigned int EBO;
glGenBuffers(1, &EBO);

  VBO와 비슷하게 glBufferData 함수를 사용하여 EBO를 바인딩하고 인덱스들을 버퍼에 복사합니다. 또한 VBO와 마찬가지로 바인딩과 언바인딩 사이에 이러한 호출을 배치합니다. 이번에는 버퍼 유형을 GL_ELEMENT_ARRAY_BUFFER로 지정합니다.


glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); 

  이번에는 GL_ELEMENT_ARRAY_BUFFER를 버퍼 타겟으로 지정했다는 것을 생각하세요. 남은 마지막 해야할 일은 glDrawArrays 함수를 glDrawElements 함수로 대체하는 것입니다. 이 함수는 인덱스 버퍼로 부터 삼각형으로 그리겠다고 지시합니다. glDrawElements 함수를 사용할 때 현재 바인딩된 element buffer object의 인덱스들을 사용하여 그리게 됩니다.


glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

  첫 번째 파라미터는 glDrawArrays 함수와 마찬가지로 우리가 그리기 원하는 모드를 지정합니다. 두 번째 파라미터는 우리가 그리고 싶은 요소의 갯수를 지정합니다. 우리는 최종적으로 6개의 정점을 그려야하기 때문에 6을 지정했습니다. 세 번째 파라미터는 인덱스의 타입입니다. 여기서는 GL_UNSIGNED_INT로 지정합니다. 마지막 파라미터는 EBO에서의 offset을 지정합니다.


  glDrawElements 함수는 GL_ELEMENT_ARRAY_BUFFER를 타겟으로 현재 바인딩 된 EBO로 부터 인덱스들을 가져옵니다. 이는 해당 EBO를 렌더링 할 때마다 바인딩해야한다는 것을 의미합니다. 다시 보는 번거로운 작업입니다. vertex array object는 또한 element buffer object 바인딩도 저장합니다. VAO가 바인딩 되어 있는 동안 element buffer object가 바인딩 되면 VAO의 버퍼 객체로서 저장됩니다. VAO를 바인딩 하면 자동으로 내부에 있는 EBO도 바인딩 됩니다.


Image of VAO's structure / what it stores now also with EBO bindings.   VAO는 타겟이 GL_ELEMENT_ARRAY_BUFFER일 때의 glBIndBuffer 함수 호출을 저장합니다. 언바인드 호출도 저장하기 때문에 VAO를 언바인드 하기전에 element array buffer를 언바인드 하지 않도록 하세요. 그렇지 않으면 EBO를 구성하지 않을 것입니다.

  초기화와 드로잉 코드는 이제 다음과 같아질 것입니다.


// ..:: 초기화 코드 :: ..
// 1. Vertex Array Object 바인딩
glBindVertexArray(VAO);
// 2. OpenGL이 사용하기 위해 vertex 리스트를 vertex 버퍼에 복사
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. OpenGL이 사용하기 위해 인덱스 리스트를 element 버퍼에 복사
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 그런 다음 vertex 속성 포인터를 세팅
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

[...]
  
// ..:: 드로잉 코드 (렌더링 루프 내부) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);

  실행 중인 프로그램은 아래와 같은 화면을 보여주어야 합니다. 왼쪽 이미지는 익숙한 화면이고 오른쪽 이미지는 wireframe mode로 그려진 사각형입니다. wireframe mode에서는 사각형을 구성하는 2개의 삼각형을 볼 수 있습니다.


A rectangle drawn using indexed rendering in OpenGL Wireframe mode
  Wireframe mode로 삼각형을 그리기 위해서 lgPolygonMode(GL_FRONT_AND_BACK, GL_LINE) 함수를 통해 OpenGL이 primitive를 어떻게 그릴 것인지 설정할 수 있습니다. 첫 번째 파라미터는 모든 삼각형의 앞과 뒤에 이것을 적용하기를 원한다는 것을 말하고 두 번째 파라미터는 그것을 선으로 그리라고 지정합니다. 그 후의 모든 드로잉 명령은 glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 함수를 통해 기본값으로 설정할 때까지 wireframe mode로 삼각형을 그려지게 됩니다.

  오류가 발생한다면 무엇을 놓쳤는지를 잘 살펴보세요. 또한 완성된 소스 코드는 여기에서 볼 수 있습니다.


  우리가 해왔던 것처럼 삼각형이나 사각형을 그렸다면 축하합니다. 여러분은 현대 OpenGL의 가장 어려운 부분 중 하나인 삼각형 그리기를 마쳤습니다. 첫 번째 삼각형을 그리기 전에 알아야할 지식들이 너무나도 많기 때문이 이부분은 어렵습니다. 고맙게도 우리는 이제 막 장벽을 넘었으며 앞으로의 강좌는 이해하기 훨씬 쉬울 것입니다.

추가 자료

연습

  학습한 개념들을 잘 이해하기 위해 몇가지 연습 문제를 만들어보았습니다. 다음 강좌로 넘어가기 전에 수행하여 여러분이 무엇이 어떻게 돌아가는지 알 수 있도록 상기시키세요.


  1. glDrawArrays 함수를 사용하여 서로의 옆에 있는 2개의 삼각형을 그려보세요. 추가적인 정점을 추가하세요: 해답
  2. 이제 2개의 다른 VAOs와 VBOs를 사용하여 그들의 데이터에 대한 똑같은 2개의 삼각형을 생성하세요: 해답
  3. 2개의 shader program을 생성하세요. 두 번째 shader proram은 다른 fragment shader를 사용합니다. 그 fragment shader는 노란색 컬러를 출력값으로 내보냅니다. 두 개의 삼각형을 모두 그려보세요: 해답




출처 : https://learnopengl.com, Joey de Vries

반응형