Skip to content

안녕 삼각형

OpenGL에서는 모든 것이 3차원 공간에 있지만, 화면이나 창은 픽셀로 이루어진 2차원 배열입니다. 따라서 OpenGL의 주요 기능은 모든 3차원 좌표를 화면에 맞는 2차원 픽셀로 변환하는 것입니다. 3차원 좌표를 2차원 픽셀로 변환하는 과정은 OpenGL의 그래픽 파이프라인(graphics pipeline)에서 처리됩니다. 그래픽 파이프라인은 크게 두 부분으로 나눌 수 있는데, 첫 번째 부분은 3차원 좌표를 화면에 그리기 위한 2차원 좌표로 변환하고, 두 번째 부분은 2차원 좌표를 실제 색상이 있는 픽셀로 변환합니다. 이 장에서는 그래픽 파이프라인에 대해 간략하게 살펴보고, 이를 활용하여 멋진 픽셀을 만드는 방법을 알아보겠습니다.

그래픽 파이프라인은 3D 좌표 세트를 입력으로 받아 화면상의 색상이 있는 2D 픽셀로 변환합니다. 그래픽 파이프라인은 여러 단계로 나눌 수 있으며, 각 단계는 이전 단계의 출력을 입력으로 필요로 합니다. 이러한 모든 단계는 고도로 전문화되어(각각 특정 기능만 수행) 병렬로 실행될 수 있습니다. 이러한 병렬 처리 특성 덕분에 오늘날의 그래픽 카드는 그래픽 파이프라인 내에서 데이터를 빠르게 처리하기 위해 수천 개의 소형 처리 코어를 갖추고 있습니다. 처리 코어는 파이프라인의 각 단계에 대해 GPU에서 작은 프로그램을 실행합니다. 이러한 작은 프로그램을 셰이더라고 합니다.

일부 셰이더는 개발자가 구성할 수 있으므로 기존의 기본 셰이더를 대체할 자체 셰이더를 작성할 수 있습니다. 이를 통해 파이프라인의 특정 부분을 훨씬 더 세밀하게 제어할 수 있으며, GPU에서 실행되므로 귀중한 CPU 시간도 절약할 수 있습니다. 셰이더는 OpenGL 셰이딩 언어(OpenGL Shading Language, GLSL)로 작성되며, 다음 장에서 이에 대해 자세히 살펴보겠습니다.

아래는 그래픽 파이프라인의 모든 단계를 추상적으로 나타낸 것입니다. 파란색 부분은 우리가 직접 셰이더를 삽입할 수 있는 영역을 나타냅니다.

보시다시피, 그래픽 파이프라인은 정점 데이터를 완전한 픽셀 렌더링으로 변환하는 과정의 각 부분을 처리하는 수많은 섹션으로 구성되어 있습니다. 파이프라인의 작동 방식을 쉽게 이해할 수 있도록 각 섹션을 간략하게 설명하겠습니다.

그래픽 파이프라인의 입력으로는 'Vertex Data'라는 배열에 삼각형을 형성해야 하는 세 개의 3D 좌표 목록을 전달합니다. 이 정점(vertex) 데이터는 정점들의 모음입니다. 각 정점은 3D 좌표에 대한 데이터 모음입니다. 정점의 데이터는 정점 속성(vertex attributes)을 사용하여 표현되며, 이 속성에는 원하는 모든 데이터를 담을 수 있지만, 간단하게 설명하기 위해 각 정점은 3D 위치와 색상 값으로만 ​​구성된다고 가정하겠습니다.

OpenGL이 좌표와 색상 값의 모음을 어떻게 처리해야 할지 알기 위해서는, 어떤 종류의 렌더링 유형을 만들 것인지에 대한 힌트를 제공해야 합니다. 데이터를 점들의 모음으로 렌더링할지, 삼각형들의 모음으로 렌더링할지, 아니면 하나의 긴 선으로 렌더링할지 등을 지정해야 합니다. 이러한 힌트를 프리미티브(primitives)라고 하며, 그리기 명령을 호출할 때 OpenGL에 전달됩니다. 이러한 힌트에는 GL_POINTS, GL_TRIANGLES, GL_LINE_STRIP 등이 있습니다.

파이프라인의 첫 번째 부분은 단일 정점을 입력으로 받는 정점 셰이더입니다. 정점 셰이더의 주요 목적은 3D 좌표를 다른 3D 좌표로 변환하는 것이며(자세한 내용은 나중에 설명), 정점 속성에 대한 기본적인 처리를 수행할 수 있도록 해줍니다.

정점 셰이더 단계의 출력은 선택적으로 지오메트리 셰이더로 전달됩니다. 지오메트리 셰이더는 기본 도형을 구성하는 정점들의 모음을 입력으로 받으며, 새로운 정점을 생성하여 새로운 (또는 다른) 기본 도형을 만들어 다른 형태를 생성할 수 있습니다. 이 예제에서는 주어진 도형에서 두 번째 삼각형을 생성합니다.

기본 도형 조립(primitive assembly) 단계는 정점 셰이더(또는 GL_POINTS가 선택된 경우 정점)에서 하나 이상의 기본 도형을 구성하는 모든 정점을 입력으로 받아 주어진 기본 도형 모양(이 경우 두 개의 삼각형)의 모든 점을 조립합니다.

기본 도형 조립 단계의 출력은 래스터화 단계(rasterization stage)로 전달되어 최종 화면의 해당 픽셀에 매핑됩니다. 이렇게 생성된 프래그먼트는 프래그먼트 셰이더에서 사용됩니다. 프래그먼트 셰이더가 실행되기 전에 클리핑이 수행됩니다. 클리핑은 사용자의 시야 범위를 벗어난 모든 프래그먼트를 제거하여 성능을 향상시킵니다.

OpenGL에서 프래그먼트는 OpenGL이 단일 픽셀을 렌더링하는 데 필요한 모든 데이터입니다.

프래그먼트 셰이더(fragment shader)의 주요 목적은 픽셀의 최종 색상을 계산하는 것이며, 일반적으로 모든 고급 OpenGL 효과가 구현되는 단계입니다. 프래그먼트 셰이더는 보통 3D 장면에 대한 데이터(예: 조명, 그림자, 조명의 색상 등)를 포함하고 있으며, 이를 사용하여 최종 픽셀 색상을 계산합니다.

모든 해당 색상 값이 결정되면 최종 객체는 알파 테스트블렌딩 단계라고 하는 마지막 단계를 거칩니다. 이 단계에서는 프래그먼트의 해당 깊이(및 스텐실) 값(나중에 자세히 설명)을 확인하고, 결과 프래그먼트가 다른 객체 앞에 있는지 뒤에 있는지 판단하여 그에 따라 폐기해야 하는지 결정합니다. 또한 알파 값(알파 값은 객체의 불투명도를 정의함)을 확인하고 그에 따라 객체를 블렌딩합니다. 따라서 프래그먼트 셰이더에서 픽셀 출력 색상이 계산되더라도 여러 삼각형을 렌더링할 때 최종 픽셀 색상은 완전히 다를 수 있습니다.

보시다시피 그래픽 파이프라인은 상당히 복잡하며 여러 가지 설정 가능한 부분을 포함하고 있습니다. 하지만 거의 모든 경우에 우리는 정점 셰이더와 프래그먼트 셰이더만 사용하면 됩니다. 지오메트리 셰이더는 선택 사항이며 일반적으로 기본 셰이더를 사용합니다. 또한 여기서는 묘사하지 않았지만 테셀레이션 단계와 변환 피드백 루프가 있으며, 이는 나중에 자세히 다루겠습니다.

최신 OpenGL에서는 정점 셰이더와 프래그먼트 셰이더를 직접 정의해야 합니다(GPU에 기본 정점/프래그먼트 셰이더가 제공되지 않습니다). 따라서 최신 OpenGL을 배우기 시작하는 것은 상당히 어려울 수 있습니다. 첫 번째 삼각형을 렌더링하기 전에 많은 지식이 필요하기 때문입니다. 하지만 이 장을 마치고 마침내 삼각형을 렌더링하게 되면 그래픽 프로그래밍에 대해 훨씬 더 많이 알게 될 것입니다.

정점 입력

무언가를 그리기 시작하려면 먼저 OpenGL에 정점 데이터를 입력해야 합니다. OpenGL은 3D 그래픽 라이브러리이므로 OpenGL에서 지정하는 모든 좌표는 3D 공간(x, y, z 좌표)에 있습니다. OpenGL은 모든 3D 좌표를 화면의 2D 픽셀로 단순히 변환하지 않습니다. OpenGL은 3축(x, y, z) 모두에서 좌표가 -1.0에서 1.0 사이의 특정 범위에 있을 때만 3D 좌표를 처리합니다. 이른바 정규화된 장치 좌표(normalized device coordinates) 범위 내에 있는 모든 좌표는 화면에 표시되고, 이 범위를 벗어난 좌표는 표시되지 않습니다.

하나의 삼각형을 렌더링하기 위해 총 세 개의 정점을 지정해야 하며, 각 정점은 3D 위치를 갖습니다. 이 정점들은 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처럼 보이게 됩니다.

Normalized Device Coordinates (NDC)

정점 셰이더에서 정점 좌표가 처리되면, x, y, z 값이 -1.0에서 1.0 사이의 작은 범위인 정규화된 장치 좌표(normalized device coordinates)로 변환됩니다. 이 범위를 벗어나는 좌표는 버려지거나 잘려나가 화면에 표시되지 않습니다. 아래 그림에서 정규화된 장치 좌표(z축 제외)로 표현된 삼각형을 확인할 수 있습니다.

일반적인 화면 좌표계와 달리 양의 y축은 위쪽 방향을 가리키고 (0,0) 좌표는 그래프의 왼쪽 상단이 아닌 중앙에 위치합니다. 최종적으로 모든 (변환된) 좌표가 이 좌표 공간 안에 있어야 하며, 그렇지 않으면 표시되지 않습니다.

그러면 NDC 좌표는 glViewport 함수로 제공한 데이터를 사용하여 뷰포트 변환(viewport transform)을 통해 화면 공간 좌표(screen-space coordinates)로 변환됩니다. 이렇게 변환된 화면 공간 좌표는 다시 프래그먼트 셰이더의 입력으로 사용될 프래그먼트 좌표로 변환됩니다.

정점 데이터가 정의되었으므로, 이를 그래픽 파이프라인의 첫 번째 프로세스인 정점 셰이더의 입력으로 보내려고 합니다. 이를 위해 GPU에 정점 데이터를 저장할 메모리를 생성하고, OpenGL이 해당 메모리를 해석하는 방식을 구성하며, 데이터를 그래픽 카드로 전송하는 방법을 지정합니다. 그러면 정점 셰이더는 메모리에 저장된 데이터를 사용하여 지정된 만큼의 정점을 처리합니다.

우리는 GPU 메모리에 많은 수의 정점을 저장할 수 있는 정점 버퍼 객체(vertex buffer objects, VBO)를 통해 메모리를 관리합니다. 이러한 버퍼 객체를 사용하는 장점은 대량의 데이터를 한 번에 그래픽 카드로 전송하고, 메모리 여유가 있다면 데이터를 저장할 수 있다는 것입니다. 즉, 데이터를 정점별로 하나씩 전송할 필요가 없습니다. CPU에서 그래픽 카드로 데이터를 전송하는 것은 상대적으로 느리기 때문에 가능한 한 번에 한 많은 데이터를 전송하는 것이 좋습니다. 데이터가 그래픽 카드 메모리에 저장되면 정점 셰이더는 거의 즉시 정점에 접근할 수 있으므로 매우 빠른 처리 속도를 구현할 수 있습니다.

정점 버퍼 객체는 OpenGL 챕터에서 설명했듯이 OpenGL 객체의 첫 번째 예입니다. OpenGL의 다른 객체와 마찬가지로 이 버퍼에도 고유한 ID가 있으므로 glGenBuffers 함수를 사용하여 버퍼 ID와 함께 정점 버퍼 객체를 생성할 수 있습니다.

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

OpenGL에는 여러 종류의 버퍼 객체가 있으며, 정점 버퍼 객체의 버퍼 유형은 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 함수는 현재 바인딩된 버퍼에 사용자 정의 데이터를 복사하는 데 사용되는 함수입니다. 첫 번째 인수는 데이터를 복사할 버퍼의 유형입니다. 현재 GL_ARRAY_BUFFER 대상에 정점 버퍼 객체가 바인딩되어 있습니다. 두 번째 인수는 버퍼에 전달할 데이터의 크기(바이트)를 지정합니다. 정점 데이터의 sizeof 값을 사용하면 충분합니다. 세 번째 매개변수는 실제로 전송할 데이터입니다.

네 번째 매개변수는 그래픽 카드가 주어진 데이터를 처리하는 방식을 지정합니다. 아래에 있는 세 가지 형식 중 하나를 사용할 수 있습니다.

  • GL_STREAM_DRAW: 데이터는 한 번만 설정되며 GPU에서 최대 몇 번만 사용됩니다.
  • GL_STATIC_DRAW: 데이터는 한 번만 설정되고 여러 번 사용됩니다.
  • GL_DYNAMIC_DRAW: 데이터가 자주 변경되고 여러 번 사용됩니다.

삼각형의 위치 데이터는 변경되지 않고, 자주 사용되며, 모든 렌더링 호출에서 동일하게 유지되므로 사용 유형은 GL_STATIC_DRAW가 가장 적합합니다. 예를 들어, 자주 변경될 가능성이 있는 데이터가 담긴 버퍼가 있는 경우, GL_DYNAMIC_DRAW 사용 유형을 사용하면 그래픽 카드가 더 빠른 쓰기가 가능한 메모리 영역에 데이터를 배치하게 됩니다.

지금까지 우리는 VBO라는 정점 버퍼 객체를 통해 그래픽 카드의 메모리에 정점 데이터를 저장해 왔습니다. 다음으로 이 데이터를 실제로 처리하는 정점 셰이더와 프래그먼트 셰이더를 만들어 보겠습니다.

정점 셰이더

정점 셰이더는 우리와 같은 개발자가 직접 프로그래밍할 수 있는 셰이더 중 하나입니다. 최신 OpenGL에서는 렌더링을 하려면 최소한 정점 셰이더와 프래그먼트 셰이더를 설정해야 하므로, 여기서는 셰이더를 간략하게 소개하고 첫 번째 삼각형을 그리는 두 가지 간단한 셰이더를 구성해 보겠습니다.

먼저 GLSL(OpenGL Shading Language) 셰이더 언어로 정점 셰이더를 작성하고, 이 셰이더를 컴파일하여 애플리케이션에서 사용할 수 있도록 해야 합니다. 아래는 GLSL로 작성된 매우 기본적인 정점 셰이더의 소스 코드입니다.

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

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

보시다시피 GLSL은 C 언어와 유사합니다. 각 셰이더는 버전 선언으로 시작합니다. OpenGL 3.3 이상 버전부터 GLSL의 버전 번호는 OpenGL 버전과 일치합니다(예: GLSL 버전 420은 OpenGL 버전 4.2에 해당). 또한 "core"라는 단어를 통해 코어 프로파일링 기능을 사용하고 있음을 명시적으로 언급합니다.

다음으로, in 키워드를 사용하여 정점 셰이더에서 입력받는 모든 정점 속성을 선언합니다. 지금은 좌표 데이터만 필요하므로 하나의 정점 속성만 있으면 됩니다. GLSL에는 뒤에 붙는 숫자에 따라 크기가 달라지는 벡터 타입이 있는데, 해당 숫자는 1개에서 4개의 부동 소수점을 저장할 수 있음을 의미합니다. 각 정점은 3D 좌표를 가지므로 aPos라는 이름으로 vec3 타입의 입력 변수를 정의합니다. 또한 layout (location = 0)을 통해 입력 변수의 위치(location)를 ​​명시적으로 설정하는데, 나중에 이 위치가 왜 필요한지 알게 될 것입니다.

벡터(Vector)

그래픽 프로그래밍에서 벡터는 공간상의 위치/방향을 깔끔하게 표현할 수 있고 유용한 수학적 속성을 지니고 있기 때문에 수학적 개념으로서 매우 자주 사용됩니다. GLSL에서 벡터는 최대 크기가 4이며, 각각 vec.x, vec.y, vec.z, vec.w를 통해 접근할 수 있습니다. 여기서 vec.w는 공간상의 좌표를 나타냅니다. 참고로 vec.w는 공간상의 위치(3D 공간에서 다루기 때문에 4D 공간은 고려하지 않음)가 아니라 원근 분할(perspective division)이라는 용도로 사용됩니다. 벡터에 대해서는 이후 장에서 더 자세히 다룰 예정입니다.

정점 셰이더의 출력을 설정하려면 미리 정의된 gl_Position 변수에 위치 데이터를 할당해야 합니다. 이 변수는 내부적으로 vec4 타입입니다. main 함수 끝에서 gl_Position에 설정한 값이 정점 셰이더의 출력으로 사용됩니다. 입력값이 3 크기의 벡터이므로 4 크기의 벡터로 형변환해야 합니다. 이를 위해 vec3 값을 vec4 생성자에 넣고 w 구성요소를 1.0f로 설정합니다(이유는 나중에 설명하겠습니다).

현재의 정점 셰이더는 입력 데이터에 대한 어떠한 처리도 하지 않고 단순히 셰이더의 출력으로 전달하기 때문에 아마도 우리가 상상할 수 있는 가장 단순한 정점 셰이더일 것입니다. 실제 응용 프로그램에서는 입력 데이터가 일반적으로 정규화된 장치 좌표계에 있지 않으므로 먼저 입력 데이터를 OpenGL의 가시 영역 내에 속하는 좌표계로 변환해야 합니다.

셰이더 컴파일하기

우리는 정점 셰이더의 소스 코드를 가져와서 일단은 코드 파일 맨 위에 있는 상수 C 문자열에 저장합니다.

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";

OpenGL이 셰이더를 사용하려면 소스 코드에서 실행중에 동적으로 컴파일해야 합니다. 먼저 ID로 참조되는 셰이더 객체를 생성해야 합니다. 따라서 정점 셰이더 객체의 아이디 변수를 부호 없는 정수로 선언하고 glCreateShader를 사용하여 셰이더 객체를 생성합니다.

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

glCreateShader 함수에 생성할 셰이더 유형을 인수로 전달합니다. 여기서는 정점 셰이더를 생성하므로 GL_VERTEX_SHADER를 전달합니다.

다음으로 셰이더 소스 코드를 셰이더 객체에 연결하고 셰이더를 컴파일합니다.

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

glShaderSource 함수는 첫 번째 인수로 컴파일된 셰이더와 연결할 셰이더 객체를 받습니다. 두 번째 인수는 소스 코드로 전달할 문자열의 개수를 지정하는데, 이는 보통 1로 둡니다. 셰세 번째 매개변수는 실제 정점 셰이더의 소스 코드이며, 네 번째 매개변수는 소스코드의 길이지만 보통 OpenGL이 알아서 처리하므로 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;
}

정점 셰이더 컴파일 중에 오류가 발견되지 않으면 컴파일이 완료됩니다.

프래그먼트 셰이더

프래그먼트 셰이더는 삼각형을 렌더링하기 위해 만들 두 번째이자 마지막 셰이더입니다. 프래그먼트 셰이더는 픽셀의 색상 출력을 계산하는 역할을 합니다. 간단하게 설명하기 위해 프래그먼트 셰이더는 항상 주황색 계열의 색상을 출력하도록 하겠습니다.

#version 330 core
out vec4 FragColor;

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

프래그먼트 셰이더는 최종 색상 출력을 정의하는 크기 4의 벡터 출력 변수 하나만 필요로 합니다. 이 색상 값은 우리가 직접 계산해야 합니다. out 키워드를 사용하여 출력 값을 선언할 수 있으며, 여기서는 간단하게 FragColor라고 명명했습니다. 다음으로, 알파 값 1.0(완전히 불투명함)을 가진, 주황색을 표현하는 벡터를 색상 출력에 할당합니다.

프래그먼트 셰이더를 컴파일하는 과정은 정점 셰이더와 유사하지만, 이번에는 셰이더 유형으로 GL_FRAGMENT_SHADER 상수를 사용합니다.

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

이제 두 셰이더 모두 컴파일되었으며, 남은 작업은 두 셰이더 객체를 하나의 렌더링에 사용할 수 있는 셰이더 프로그램(Shader program)으로 링크하는 것뿐입니다. 여기서도 컴파일 오류가 없는지 꼭 확인하세요!

셰이더 프로그램

셰이더 프로그램 객체는 여러 셰이더를 결합한 최종 링크 버전입니다. 최근 컴파일된 셰이더를 사용하려면 셰이더 프로그램 객체에 링크한 다음, 객체를 렌더링할 때 이 셰이더 프로그램을 활성화해야 합니다. 활성화된 셰이더 프로그램의 셰이더는 렌더링 호출 시 사용됩니다.

셰이더를 프로그램에 연결할 때 각 셰이더의 출력을 다음 셰이더의 입력에 연결합니다. 출력과 입력이 일치하지 않으면 이 단계에서 연결 오류가 발생합니다.

프로그램 객체를 생성하는 것은 간단합니다.

unsigned int shaderProgram;
shaderProgram = glCreateProgram();

glCreateProgram 함수는 프로그램을 생성하고 새로 생성된 프로그램 객체의 ID 참조를 반환합니다. 이제 이전에 컴파일한 셰이더를 프로그램 객체에 연결한 다음 glLinkProgram 함수를 사용하여 링크해야 합니다.

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

코드는 꽤 직관적입니다. 셰이더를 프로그램에 연결하고 glLinkProgram을 통해 연결합니다.

셰이더 컴파일과 마찬가지로 셰이더 프로그램 링크 실패 여부를 확인하고 해당 로그를 가져올 수 있습니다. 하지만 glGetShaderivglGetShaderInfoLog 대신 다음 함수를 사용합니다.

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

결과적으로 프로그램 객체가 생성되는데, 이 객체를 glUseProgram 함수에 인수로 전달하여 활성화할 수 있습니다.

glUseProgram(shaderProgram);

glUseProgram 이후의 모든 셰이더 및 렌더링 호출은 이제 이 프로그램 객체(셰이더)를 사용하게 됩니다.

아, 그리고 셰이더 객체를 프로그램 객체에 연결했으면 이제 삭제하는 걸 잊지 마세요. 더 이상 필요 없으니까요.

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);  

지금 우리는 입력 정점 데이터를 GPU로 보내고, 정점 셰이더와 프래그먼트 셰이더 내에서 정점 데이터를 어떻게 처리해야 하는지 GPU에 지시했습니다. 거의 다 왔지만 아직 완벽하지는 않습니다. OpenGL은 아직 메모리에 있는 정점 데이터를 어떻게 해석하고 정점 셰이더의 속성에 어떻게 연결해야 하는지 알지 못합니다. 우리가 친절하게 OpenGL에게 그 방법을 알려줄 것입니다.

정점 속성 연결

정점 셰이더를 사용하면 정점 속성 형태로 모든 입력의 모양을 원하는대로 지정할 수 있습니다. 이는 뛰어난 유연성을 제공하지만, 입력 데이터의 어떤 부분이 정점 셰이더의 어떤 정점 속성에 해당하는지 수동으로 지정해야 한다는 것을 의미합니다. 즉, 렌더링 전에 OpenGL이 정점 데이터를 어떻게 해석해야 하는지 지정해야 합니다.

정점 버퍼 데이터는 다음과 같은 형식으로 되어 있습니다.

  • 위치 데이터는 32비트(4바이트) 부동소수점 값으로 저장됩니다.
  • 각 위치는 이러한 값 중 3개로 구성됩니다.
  • 세 개의 값으로 이루어진 각 세트 사이에는 공백(또는 다른 종류의 값)이 없습니다. 값들이 배열에 촘촘하게 채워져(tightly packed) 있습니다.
  • 데이터의 첫 번째 값은 버퍼의 시작 부분에 있습니다.

이러한 정보를 바탕으로 glVertexAttribPointer 함수를 사용하여 OpenGL에게 정점 속성별 정점 데이터를 어떻게 해석해야 하는지 알려줄 수 있습니다.

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

glVertexAttribPointer 함수는 매개변수가 꽤 많으므로 하나씩 자세히 살펴보겠습니다.

  • 첫 번째 매개변수는 우리가 설정하려는 정점의 속성이 무엇인지를 지정합니다. 기억하다시피, 우리는 정점 셰이더에서 layout (location = 0)을 사용해 position이라는 정점 속성의 location을 0으로 지정했습니다. 우리는 location이 0인 정점 속성에 접근해야하므로 첫 번째 매개변수로 0을 전달합니다.
  • 다음 인수는 정점 속성의 크기를 지정합니다. 정점 속성은 vec3 형식이므로 3개의 값으로 구성됩니다.
  • 세 번째 인수는 데이터 유형을 지정하는데 우리는 vec3를 사용하므로, 이는 GL_FLOAT입니다(GLSL에서 모든 벡터는 float 타입으로 구성됩니다).
  • 다음 인수는 데이터를 정규화할지 여부를 지정합니다. 정수형 데이터(int, byte)를 입력하고 이 값을 GL_TRUE로 설정하면 정수 데이터는 0(부호 있는 데이터의 경우 -1)으로 정규화되고 부동 소수점으로 변환될 때 1이 됩니다. 하지만 이 기능은 우리에게 필요하지 않으므로 GL_FALSE로 유지합니다.
  • 다섯 번째 인수는 스트라이드(stride)라고 하며, 연속된 정점 속성 값 사이의 간격을 나타냅니다. 다음 위치 데이터 세트는 float 크기의 정확히 3배만큼 떨어져 있으므로, 이 값을 스트라이드로 지정합니다. 배열이 촘촘하게 채워져(tightly packed) 있는 경우(다음 정점 속성 값 사이에 간격이 없는 경우)에는 스트라이드를 0으로 지정하여 OpenGL이 자동으로 스트라이드를 결정하도록 할 수도 있습니다(단, 값이 촘촘하게 채워져 있을 때만 작동합니다). 정점 속성이 더 많아질수록 각 정점 속성 값 사이의 간격을 신중하게 정의해야 하지만, 이에 대한 자세한 예시는 나중에 살펴보겠습니다.
  • 마지막 매개변수는 void* 타입이므로 특이한 형변환이 필요합니다. 이 값은 버퍼에서 위치 데이터가 시작되는 위치의 오프셋입니다. 위치 데이터가 데이터 배열의 시작 부분에 있으므로 이 값은 0입니다. 이 매개변수에 대해서는 나중에 더 자세히 살펴보겠습니다.

다섯 번째 인수

우리는 하나의 정점 좌표를 표현하기 위해 vec3를 사용합니다. float는 한 숫자가 4바이트를 차지하므로, X,Y,Z 축으로 총 3개이기 때문에 12바이트가 됩니다.

또한 우리도 촘촘하게 채워져 있는데 0을 써도 되는거 아니냐? 라고 물어볼 수 있습니다. 네 맞습니다. 우리도 촘촘하게 데이터를 채웠습니다. 하지만 우리는 추후에 다른 종류의 데이터를 넣을것이기 때문에 3*sizeof(float)를 대신 사용합니다.

이제 OpenGL이 정점 데이터를 해석하는 방식을 지정했으므로, glEnableVertexAttribArray 함수를 사용하여 정점 속성의 위치를 ​​인수로 전달하여 정점 속성을 활성화해야 합니다. 정점 속성은 기본적으로 비활성화되어 있습니다. 이 모든 과정을 거쳤다면 드디어 모든 설정이 완료되었습니다. 정점 버퍼 객체를 사용하여 버퍼에 정점 데이터를 초기화하고, 정점 셰이더와 프래그먼트 셰이더를 설정했으며, OpenGL에 정점 데이터를 정점 셰이더의 정점 속성에 연결하는 방법을 알려주었습니다. 이제 OpenGL에서 객체를 그리는 전채 과정은 다음과 같습니다.

// 0. OpenGL에서 사용할 수 있도록 정점 배열을 버퍼에 복사합니다.
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 그다음 정점 속성 포인터를 설정합니다.
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
// 2. 객체를 렌더링할 때 우리의 셰이더 프로그램을 사용하도록 설정합니다.
glUseProgram(shaderProgram);
// 3. 이제 물체를 그립니다.
someOpenGLFunctionThatDrawsOurTriangle();

객체를 그릴 때마다 이 과정을 반복해야 합니다. 언뜻 보기에는 별것 아닌 것처럼 보일 수 있지만, 정점 속성이 5개 이상이고 객체가 수백 개에 달한다고 가정해 보세요(이는 흔한 경우입니다). 각 객체에 맞는 버퍼 객체를 바인딩하고 모든 정점 속성을 설정하는 것은 금세 번거로운 작업이 됩니다. 만약 이러한 모든 상태 설정을 하나의 객체에 저장하고, 해당 객체를 바인딩하여 상태를 복원할 수 있는 방법이 있다면 어떨까요?

정점 배열 객체

정점 배열 객체(Vertex Array Object, VAO)는 정점 버퍼 객체(VBO)처럼 바인딩할 수 있으며, 이후의 모든 정점 속성 호출은 VAO 내부에 저장됩니다. 이러한 방식의 장점은 정점 속성 포인터를 설정할 때 해당 호출을 한 번만 수행하면 되고, 객체를 그릴 때마다 해당 VAO를 바인딩하기만 하면 된다는 것입니다. 따라서 서로 다른 정점 데이터 및 속성 구성 간 전환이 다른 VAO를 바인딩하는 것만큼 간편해집니다. 설정한 모든 상태는 VAO 내부에 저장됩니다.

Core OpenGL은 정점 입력값을 처리하는 방법을 알기 위해 VAO가 필요합니다. VAO를 바인딩하지 않으면 OpenGL은 아무것도 그리지 않을 가능성이 높습니다.

정점 배열 객체는 다음과 같은 함수 호출로 변경된 상태를 저장합니다.

  • glEnableVertexAttribArray 또는 glDisableVertexAttribArray 호출.
  • glVertexAttribPointer를 통한 정점 속성 구성.
  • 정점 버퍼 객체는 glVertexAttribPointer 호출을 통해 정점 속성과 연결됩니다.

VAO를 생성하는 과정은 VBO를 생성하는 과정과 유사합니다.

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

VAO를 사용하려면 glBindVertexArray 함수를 사용하여 VAO를 바인딩하기만 하면 됩니다. 그 후에는 해당 VBO와 속성 포인터를 바인딩/구성하고, 나중에 다시 사용할 수 있도록 VAO 바인딩을 해제해야 합니다. 객체를 그릴 때는 객체를 그리기 전에 원하는 설정으로 VAO를 바인딩하기만 하면 됩니다. 코드로 표현하면 대략 다음과 같습니다.

// ..:: 초기화 코드 (객체가 자주 변경되지 않는 한 한 번만 실행) :: ..
// 1. 정점 배열 객체 바인딩
glBindVertexArray(VAO);
// 2. OpenGL에서 사용할 수 있도록 정점 배열을 버퍼에 복사합니다.
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 그런 다음 정점 속성 포인터를 설정합니다.
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  


[...]

// ..:: 그리기 코드 (렌더링 루프 내) :: ..
// 4. 객체를 그립니다
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();

이게 전부입니다! 지난 수십억년에 걸쳐 우리가 했던 모든 작업이 바로 이 순간을 위한 것이었습니다. 정점 속성 구성과 사용할 VBO를 저장하는 VAO를 만드는 것이죠. 일반적으로 여러 객체를 그려야 할 때는 먼저 모든 VAO(필요한 VBO와 속성 포인터 포함)를 생성/구성하고 나중에 사용할 수 있도록 저장합니다. 객체 중 하나를 그릴 때는 해당 VAO를 가져와 바인딩한 다음 객체를 그리고 VAO 바인딩을 해제합니다.

우리 모두가 기다려온 삼각형

우리 모두가 기다려온 삼각형(번역가 포함)

원하는 객체를 그리기 위해 OpenGL은 현재 활성화된 셰이더, 이전에 정의된 정점 속성 구성 및 VBO의 정점 데이터(VAO딩를 통해 간접적으로 바인됨)를 사용하여 기본 도형을 그리는 glDrawArrays 함수를 제공합니다.

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

glDrawArrays 함수는 첫 번째 인수로 OpenGL 프리미티브 타입(primitive type)을 받습니다. 처음에 삼각형을 그리겠다고 했으니, 그리고 거짓말을 하고 싶지 않으니, GL_TRIANGLES를 전달합니다. 두 번째 인수는 그릴 정점 배열의 시작 인덱스를 지정합니다. 이 값은 0으로 둡니다. 마지막 인수는 그릴 정점의 개수를 지정합니다. 3개를 전달합니다(데이터에서 정확히 3개의 정점으로 이루어진 삼각형 하나만 렌더링하기 때문입니다).

이제 코드를 컴파일해 보고 오류가 발생하면 역순으로 확인해 보세요. 애플리케이션 컴파일이 완료되면 다음과 같은 결과가 나타날 것입니다.

전체 프로그램의 소스 코드는 여기에서 확인할 수 있습니다.

출력 결과가 동일하지 않다면 아마도 과정에서 뭔가 잘못된 부분이 있을 가능성이 높으니 전체 소스 코드를 확인하고 누락된 부분이 있는지 살펴보세요.

이거 하나 그릴려고 몇달을 쓴거야...

요소 버퍼 객체

정점을 렌더링할 때 마지막으로 논의하고 싶은 한 가지는 요소 버퍼 객체(element buffer objects, EBO)입니다. 요소 버퍼 객체의 작동 방식을 설명하기 위해 예를 들어 보겠습니다. 삼각형 대신 사각형을 그리고 싶다고 가정해 봅시다. 두 개의 삼각형을 사용하여 사각형을 그릴 수 있습니다(OpenGL은 주로 삼각형을 사용합니다). 이렇게 하면 다음과 같은 정점 집합이 생성됩니다.

float vertices[] = {
    // first triangle
     0.5f,  0.5f, 0.0f,  // top right
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f,  0.5f, 0.0f,  // top left 
    // second triangle
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f, -0.5f, 0.0f,  // bottom left
    -0.5f,  0.5f, 0.0f   // top left
};

보시다시피, 지정된 꼭짓점들 사이에 중복이 있습니다. 오른쪽 아래와 왼쪽 위를 두 번씩 지정했죠! 같은 사각형을 6개가 아닌 4개의 꼭짓점만으로도 표현할 수 있기 때문에 이는 50%의 오버헤드를 발생시킵니다. 삼각형이 수천 개 이상인 복잡한 모델에서는 이러한 중복이 더욱 심해질 것입니다. 더 나은 해결책은 고유한 꼭짓점만 저장하고, 이 꼭짓점들을 어떤 순서로 그릴지 지정하는 것입니다. 이렇게 하면 사각형에 필요한 꼭짓점 4개만 저장하고, 그리는 순서만 지정하면 됩니다. OpenGL에서 이런 기능을 제공한다면 정말 좋을 텐데요.

다행히도, 요소 버퍼 객체(EBO)가 바로 그런 방식으로 작동합니다. EBO는 정점 버퍼 객체와 마찬가지로 OpenGL이 어떤 정점을 그릴지 결정하는 데 사용하는 인덱스를 저장하는 버퍼입니다. 이러한 인덱스 기반 그리기(indexed drawing) 방식이 바로 우리가 해결하고자 하는 문제의 정답입니다. 시작하려면 먼저 (고유한) 정점과 해당 정점을 사각형으로 그릴 인덱스를 지정해야 합니다.

float vertices[] = {
     0.5f,  0.5f, 0.0f,  // top right
     0.5f, -0.5f, 0.0f,  // bottom right
    -0.5f, -0.5f, 0.0f,  // bottom left
    -0.5f,  0.5f, 0.0f   // top left 
};
unsigned int indices[] = {  // note that we start from 0!
    0, 1, 3,   // first triangle
    1, 2, 3    // second triangle
};  

인덱스를 사용하면 6개가 아닌 4개의 정점만 필요하다는 것을 알 수 있습니다. 다음으로 요소 버퍼 객체를 생성해야 합니다.

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

VBO와 유사하게 EBO를 바인딩하고 glBufferData를 사용하여 인덱스를 버퍼에 복사합니다. 또한 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를 사용하면 현재 바인딩된 요소 버퍼 객체에 제공된 인덱스를 사용하여 그림을 그립니다.

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

첫 번째 인수는 glDrawArrays와 유사하게 그릴 모드를 지정합니다. 두 번째 인수는 그릴 요소의 개수입니다. 여기서는 6개의 인덱스를 지정했으므로 총 6개의 정점을 그립니다. 세 번째 인수는 인덱스의 데이터 유형으로, GL_UNSIGNED_INT 로 하겠습니다. 마지막 인수는 EBO 내의 오프셋을 지정하거나 인덱스 배열을 전달할 수 있지만, 여기서는 0으로 둡니다.

glDrawElements 함수는 현재 GL_ELEMENT_ARRAY_BUFFER 타겟에 바인딩된 EBO에서 인덱스를 가져옵니다. 즉, 인덱스를 사용하여 객체를 렌더링할 때마다 해당 EBO를 바인딩해야 하므로 다소 번거롭습니다. 다행히 정점 배열 객체는 요소 버퍼 객체 바인딩 정보도 관리합니다. VAO가 바인딩된 상태에서 마지막으로 바인딩된 요소 버퍼 객체가 해당 VAO의 요소 버퍼 객체로 저장됩니다. 따라서 VAO에 바인딩하면 해당 EBO도 자동으로 바인딩됩니다.

VAO는 대상이 GL_ELEMENT_ARRAY_BUFFER인 경우 glBindBuffer 호출을 저장합니다. 이는 unbind 호출도 저장한다는 의미이므로 VAO를 언바인딩하기 전에 요소 배열 버퍼를 언바인딩하지 않도록 주의해야 합니다. 만약 VAO를 언바인딩하기 전에 EBO를 언바인딩 한다면 EBO가 VAO에 저장되지 않습니다.

결과적으로 생성된 초기화 및 그리기 코드는 다음과 같습니다.

// ..:: 초기화 코드 :: ..
// 1. 정점 배열 객체 바인드
glBindVertexArray(VAO);
// 2. OpenGL에서 사용할 수 있도록 정점 배열을 정점 버퍼에 복사합니다.
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. OpenGL에서 사용할 수 있도록 인덱스 배열을 요소 버퍼에 복사합니다.
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 그다음 정점 속성 포인터를 설정합니다.
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)로 그려진 사각형입니다. 와이어프레임 사각형을 보면 사각형이 실제로 두 개의 삼각형으로 구성되어 있음을 알 수 있습니다.

와이어프레임 모드

삼각형을 와이어프레임 모드로 그리려면 glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) 함수를 사용하여 OpenGL이 기본 도형을 그리는 방식을 설정할 수 있습니다. 첫 번째 인수는 모든 삼각형의 앞면과 뒷면에 적용하고, 두 번째 인수는 선으로 그리도록 지정합니다. 이후의 모든 그리기 호출은 glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 함수를 사용하여 기본값으로 되돌릴 때까지 와이어프레임 모드로 렌더링됩니다.

오류가 있다면 역순으로 확인하여 누락된 부분이 있는지 살펴보세요. 전체 소스 코드는 여기에서 확인할 수 있습니다.

저처럼 삼각형이나 사각형을 그리는 데 성공하셨다면 축하드립니다! 최신 OpenGL에서 가장 어려운 부분 중 하나인 첫 삼각형 그리기 단계를 통과하셨습니다. 첫 삼각형을 그리기 위해서는 상당한 지식이 필요하기 때문에 이 단계는 쉽지 않습니다. 하지만 이제 그 장벽을 넘었으니 앞으로의 내용은 훨씬 이해하기 쉬울 것입니다.

축하합니다! 번역의 퀄리티가 마음에 들었길 바랍니다. 마음에 든다면 이 프로젝트의 깃허브에 스타 한번 눌러주시면 감사하겠습니다. 원본 프로젝트의 깃허브도 확인해보세요!

참고자료

연습 문제

이 과정에서 다룬 개념들을 제대로 이해할 수 있도록 몇 가지 연습 문제를 준비했습니다. 다음 주제로 넘어가기 전에 이 연습 문제들을 풀어보면서 내용을 확실히 파악하는 것이 좋습니다.

  • glDrawArrays를 사용하여 두 개의 삼각형을 나란히 그려보세요. 데이터에 더 많은 정점을 추가하면 됩니다. (해결 방법)
  • 이제 서로 다른 두 개의 VAO와 VBO를 사용하여 동일한 두 개의 삼각형을 생성해 보세요. (해결 방법)
  • 두 개의 셰이더 프로그램을 작성하고, 두 번째 프로그램은 노란색을 출력하는 다른 프래그먼트 셰이더를 사용하도록 합니다. 그런 다음 두 삼각형 중 하나가 노란색을 출력하도록 다시 그립니다. (해결 방법)