21 minute read

내용의 이해를 돕기위해 원본의 글보다 조금 더 카테고리를 세분화했습니다. 세분화 된 부분은 개요부분이 추가되었으며, 나머지는 동일합니다.

0. 개요

GLFW를 시작하고 실행할 수 있는지 봅시다. 먼저 .cpp 파일을 만들고 새로 만든 파일의 맨 위에 다음 포함을 추가합니다.

#include <glad/glad.h>
#include <GLFW/glfw3.h>

OpenGL에서는 모든 것이 3D 공간에 있지만 화면이나 윈도우 창은 픽셀의 2D 배열이므로 OpenGL 작업의 대부분은 모든 3D 좌표들을 화면에 맞게 2D 픽셀로 변환하는 것입니다. 3D 좌표를 2D 픽셀로 변환하는 과정은 OpenGL의 그래픽스 파이프라인에 의해 관리됩니다. 그래픽스 파이프라인은 크게 두 파트로 나눌 수 있습니다. 첫 번째는 3D 좌표를 2D 좌표로 변환하는 것이고 두 번째 파트는 2D 좌표를 실제로 색상이 입혀진 픽셀로 변환하는 것입니다. 이 장에서는 그래픽스 파이프라인과 이를 어떻게 활용하여 멋진 픽셀을 만들 수 있는지 간략하게 설명합니다.

그래픽스 파이프라인은 3D 좌표 집합(3D coordinates set)을 입력으로 받아 화면의 컬러2D 픽셀로 변환합니다. 그래픽스 파이프라인은 각 단계를 여러 단계로 나눌 수 있는데, 이전 단계의 출력을 현재 단계의 입력으로 요구하는 여러 단계로 나눌 수 있습니다. 이 모든 단계는 고도로 전문화되어 있으며(하나의 특정 기능이 있음) 병렬로 쉽게 실행할 수 있습니다. 이러한 병렬 특성으로 인해 오늘날의 그래픽 카드에는 그래픽스 파이프라인 내에서 데이터를 빠르게 처리할 수 있는 수천 개의 작은 처리 코어가 있습니다. 처리 코어는 파이프라인의 각 단계에 대해 GPU에서 작은 프로그램을 실행합니다. 이러한 작은 프로그램을 셰이더(Shader)라고 합니다.

셰이더 중 일부는 기존에 있던 기본 셰이더를 대체하기 위해 자체 셰이더를 작성할 수 있도록 개발자가 구성할 수 있습니다. 이를 통해 파이프라인의 특정 부분을 훨씬 더 세밀하게 제어할 수 있으며 GPU에서 실행되기 때문에 귀중한 CPU 시간도 절약할 수 있습니다. 셰이더는 GLSL(OpenGL Shading Language)로 작성되었으며 다음 장에서 이에 대해 더 자세히 알아볼 것입니다.

요약

  • 3D 좌표를 2D 픽셀로 변환하는 과정은 그래픽스 파이프라인에 의해 관리된다.
  • 그래픽스 파이프라인은 크게 두 가지로 나눌 수 있다.
    1. 3D 좌표를 2D 좌표로 변환
    2. 변환된 2D 좌표에 실제로 색상이 입혀진 픽셀로 변환 (2D coordinate와 pixel의 개념적인 차이도 존재하는 것 같다.)
  • 그래픽스 파이프라인를 이루는 단계는 각각 하나의 특정 기능이 있고, 병렬로 쉽게 실행할 수 있다. (말 그대로 파이프라이닝이 가능한 것 같다.)
  • 그래픽 카드안의 처리 코어들은 파이프라인의 각 단계에 대해 GPU에서 작은 프로그램을 실행하고, 이 작은 프로그램을 셰이더(shader)라고 한다.

아래에서 그래픽스 파이프라인의 모든 단계에 대한 추상적 표현을 찾을 수 있습니다. 파란색 섹션은 자체 셰이더(프로그래머가 작성한 셰이더)를 주입할 수 있는 섹션을 나타냅니다.

pipeline

보시다시피, 그래픽스 파이프라인에는 vertex data를 완전히 렌더링된 픽셀로 변환하기 위해 특정 부분을 각각 처리하는 많은 섹션을 가지고 있습니다. 파이프라인이 어떻게 작동하는지에 대한 좋은 개요를 보기 위해 파이프라인의 각 부분을 단순화된 방식으로 간략하게 설명합니다.

그래픽스 파이프라인에 대한 입력으로 세 개의 3D 좌표 리스트를 전달합니다. 3D 좌표의 리스트는 삼각형을 구성하며 정점 데이터(Vetex Data)라고 불립니다. 이 vertex data는 정점들의 모음(collection)입니다. 정점(Vertex)은 3D좌표별 데이터의 모음입니다. 정점 데이터는 정점 속성(vertex attributes)을 사용하여 나타낼 수 있습니다. vertex attribute은 우리가 원하는 어떤 데이터든 포함할 수 있지만 단순하게 각 정점은 3D position과 일부 색상 값으로 구성되어 있다고 가정하겠습니다.

OpenGL이 좌표 및 색상 값의 집합으로 무엇을 만들지 알도록 하려면 OpenGL은 데이터로 형성하려는 렌더 유형(render types)의 종류를 알려주어야 합니다. 데이터를 점의 모음, 삼각형의 모음 또는 하나의 긴 선으로 렌더링하기를 원하나요? 이러한 종류를 알려주는 힌트를 프리미티브(primitives)라고 하며 drawing 명령을 호출하는 동안 OpenGL에 제공됩니다. 이러한 힌트 중의 일부의 예시는 GL_POINTS, GL_TRIANGLES 및 GL_LINE_STRIP입니다.

0.1 vertex shader에 대한 개요

파이프라인의 첫 번째 파트는 단일 정점(single vertex)을 입력으로 받는 vertex shader(vertex shader)입니다. vertex shader의 주요 목적은 3D좌표를 다른 3D좌표로 변환하는 것이며(자세한 내용은 나중에) vertex shader를 사용하면 vertex attributes에 대한 몇 가지 기본 처리를 수행할 수 있습니다.

0.2 프리미티브 어셈블리

프리미티브 어셈블리 단계는 프리미티를 형성하는 vertex shader로부터 모든 정점(또는 GL_POINT가 선택된 정점)을 입력으로 받습니다. 그리고 primitive shape에서 주어진 모든 점, 점들을 조합합니다. 이 경우에서는 삼각형입니다.

0.3 geometry shader

프리미티브 어셈블리 단계의 출력은 geometry shader(geometry shader)로 넘어갑니다. geometry shader는 프리미티브를 형성하는 정점의 집합을 입력으로 받고, 새로운(또는 다른) 프리미티브를 형성하기 위해 새로운 정점을 방출하여 다른 모양을 생성할 수 있습니다. 이 예제의 경우 주어진 모양에서 두 번째 삼각형을 생성합니다.

0.4 rasterization

그런 다음 geometry shader의 출력이 rasterization 단계로 전달되어 결과 프리미티브를 최종 화면의 적절한 픽셀에 매핑하여 fragment shader가 사용할 프래그먼트를 생성합니다. fragment shader가 실행되기 전에 클리핑이 수행됩니다. 클리핑은 보기 외부에 있는 모든 fragment를 버리므로 성능이 향상됩니다.

즉, 여기까지의 순서는

  1. Vertex shader
  2. Primitive assembly(이미지를 보면shape assembly라고도 하는 것 같음.)
  3. Geometry shader
  4. Rasterziation
  5. clipping (이미지에는 없지만, fragment shader 실행전에 클리핑이 수행된다고 한다.)

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

0.5 fragment shader

fragment shader의 주요 목적은 픽셀의 최종 색상을 계산하는 것이며 일반적으로 모든 고급 OpenGL 효과가 발생하는 단계입니다. 일반적으로 fragment shader에는 최종 픽셀 색상(예: 조명, 그림자, 조명 색상 등)을 계산하는 데 사용할 수 있는 3D 장면에 대한 데이터가 포함되어 있습니다.

0.6 Tests And Blending

해당하는 모든 색상 값이 결정된 후 최종 개체는 알파(alpha) 테스트 및 블렌딩(blending) 단계(alpha test and blending stage)라고 하는 단계를 하나 더 거칩니다. 이 단계에서는 프래그먼트의 해당 깊이(및 스텐실) 값(나중에 이 값에 대해 알아보겠습니다)을 확인하고 결과로 나온 프래그먼트(resulting fragment)가 다른 객체 앞이나 뒤에 있는지 확인하고 그에 따라 폐기해야 합니다. 또한 알파 테스트와 블렌딩 단계에서는 알파 값(알파 값은 개체의 불투명도를 정의함)을 확인하고 그에 따라 객체를 혼합합니다. 따라서 fragment shader에서 픽셀 출력 색상을 계산하더라도 여러 삼각형을 렌더링할 때 최종 픽셀 색상은 여전히 완전히 다를 수 있습니다.

0.7 정리

보시다시피 그래픽스 파이프라인은 상당히 복잡한 전체이며 많은 구성 가능한 부분을 포함합니다. 그러나 거의 모든 경우에 버텍스 및 fragment shader만 사용하면 됩니다. geometry shader는 선택 사항이며 일반적으로 기본 셰이더로 유지됩니다. 여기에 설명하지 않은 테셀레이션 단계와 변환 피드백 루프(transform feedback loop)도 있지만 나중에 설명하겠습니다.

최신 OpenGL에서는 최소한 우리 자신의 버텍스 및 fragment shader를 정의해야 합니다(GPU에는 기본 정점/조각 셰이더가 없음). 이러한 이유로 첫 번째 삼각형을 렌더링할 수 있으려면 많은 지식이 필요하기 때문에 최신 OpenGL 학습을 시작하는 것이 종종 매우 어렵습니다. 이 장의 끝에서 삼각형을 마침내 렌더링하게 되면 그래픽 프로그래밍에 대해 더 많이 알게 될 것입니다.

(각 소제목에 순서를 붙여봅니다. 원래 튜토리얼 문서에서는 없지만, 이것이 조금 더 순서를 머릿속에 되새기면서 볼 수 있을 것 같습니다.)

1. Vertex input

무언가를 그리기 시작하려면 먼저 OpenGL에게 몇몇의 입력 vertex data를 주어야 합니다. OpenGL은 3D 그래픽 라이브러리이므로 OpenGL에서 지정하는 모든 좌표들은 3D 좌표(x, y 그리고 z좌표)입니다. OpenGL은 단순히 모든 3D 좌표를 화면의 2D 픽셀로 변환하지 않습니다. OpenGL은 3개의 축(x, y, z) 모두에서 -1.0과 1.0 사이의 특정 범위에 있을 때만 3D 좌표를 처리합니다. 표준화된 장치 좌표 범위라고 하는 이 범위 내의 모든 좌표는 화면에 표시됩니다(이 영역 외부의 모든 좌표들은 표시되지 않음).

단일 삼각형을 렌더링하고 싶기 때문에 각 정점마다 3D 포지션을 갖는 총 3개의 정점을 지정하려고 합니다. float 배열의 정규화된 장치 좌표(OpenGL의 가시 영역(visible region))로 정의합니다.

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

OpenGL은 3D 공간에서 작동하기 때문에 각 꼭짓점이 0.0의 z 좌표를 갖는 2D 삼각형을 렌더링합니다. 이렇게 하면 삼각형의 깊이가 동일하게 유지되어 2D처럼 보입니다.

Normalized Device Coordinates(정규화된 장치 좌표(NDC))
정점 좌표가 vertex shader에서 처리되면 x, y 및 z 값이 -1.0에서 1.0까지 변하는 작은 공간인 정규화된 장치 좌표에 있어야 합니다. 이 범위를 벗어나는 모든 좌표는 삭제/잘림이 되어서 화면에 표시되지 않습니다. 아래에서 정규화된 장치 좌표 내에서 지정한 삼각형을 볼 수 있습니다(z축 무시).

ndc 일반적인 화면 좌표(screen coordinate)와 달리 양의 y축의 점이 위를 향하는 것과 (0,0) 좌표는 왼쪽 상단이 아닌 그래프 중앙에 있습니다. 결국 모든 (변환된) 좌표가 아 NDC 좌표 공간에서 끝나기를 원합니다. 그렇지 않으면 표시되지 않습니다. 그러면 NDC 좌표가 glViewport에서 제공한 데이터를 사용하여 뷰포트 변환을 통해 화면 공간(screen-space) 좌표로 변환됩니다. 그런 다음 결과 화면 공간 좌표가 fragment shader에 대한 입력인 프래그먼트로 변환됩니다.

vertex data가 정의되면 이를 vertex shader로 보냅니다. vertex shader는 그래픽스 파이프라인의 첫 번째 프로세스에 해당합니다. GPU에 vertex data를 저장할 메모리를 할당하고, OpenGL이 메모리를 어떻게 해석할 것인지를 구성하고 그래픽카드로 데이터를 어떻게 보낼지를 지정함으로 작업이 완료됩니다. 그런 다음 메모리에서 지시한 만큼의 vertex를 vertex shader에서 처리합니다.

우리는 메모리를 vertex buffer objects(VBO)를 통해 관리합니다. VBO는 GPU의 메모리에 많은 정점들을 저장할 수 있습니다.

이러한 buffer object를 사용하는 이점은 한 번에 하나의 vertex data를 보낼 필요 없이 많은 양의 데이터를 한 번에 그래픽 카드로 보낼 수 있고 메모리가 충분하면 거기에 보관할 수 있다는 것입니다. CPU에서 그래픽 카드로 데이터를 보내는 것은 상대적으로 느리기 때문에 가능한 한 많은 데이터를 한 번에 보내려고 합니다. 그래픽 카드의 메모리에 데이터가 있으면 vertex shader는 정점에 거의 즉시 접근가능 하기에 매우 빠르게 처리할 수 있습니다.

vertex buffer object는 OpenGL 챕터에서 말했던 것처럼 OpenGL 객체의 첫 번째 발생(occurrence / 생성이라고 해야하나)입니다. OpenGL의 모든 객체와 마찬가지로 이 버퍼에는 해당 버퍼에 해당하는 고유 ID가 있으므로 glGenBuffers 함수를 사용하여 버퍼 ID 하나를 생성할 수 있습니다.

glGenBuffers 함수 예시 :

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

OpenGL은 buffer object들의 타입을 많이 가지고 있고 vertex buffer object의 버퍼 타입은 GL_ARRAY_BUFFER 입니다. OpenGL은 다른 버퍼 타입을 가지고 있으면 여러 개의 버퍼에 한 번에 바인딩하는 것이 가능합니다. 우리는 새로 생성된 버퍼를 glBindBuffer 함수를 사용하여 GL_ARRAY_BUFFER로 바인딩할 수 있습니다.

glBindBuffer(GL_ARRAY_BUFFER, VBO);  

그 시점(바인딩 한 시점)부터 어떤 버퍼 호출(GL_ARRAY_BUFFER가 대상인)이든 현재 바인딩되어 있는 VBO를 구성하는데 사용됩니다. 그런 다음 이전에 정의된 vertex data를 버퍼의 메모리에 복사하는 glBufferData 함수를 호출할 수 있습니다.

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

glBufferData는 사용자 정의 데이터를 현재 바인딩된 버퍼에 복사하도록 특별히 지정된 함수입니다.

  1. 첫 번째 인수는 데이터를 복사하려는 버퍼의 유형입니다. (vertex buffer object는 GL_ARRAY_BUFFER를 대상으로 바인딩됩니다.)
  2. 두 번째 인수는 버퍼에 전달하려는 데이터의 크기(바이트)를 지정합니다. vertex data의 간단한 크기로 충분합니다.
  3. 세 번째 매개변수는 보내려는 실제 데이터입니다.

  4. 네 번째 매개변수는 그래픽 카드가 주어진 데이터를 관리하는 방법을 지정합니다. 이것은 3가지 형태를 취할 수 있습니다:
    • GL_STREAM_DRAW: 데이터가 한 번만 설정되고 GPU에서 최대 몇 번 사용됩니다.
    • GL_STATIC_DRAW: 데이터는 한 번만 설정되고 여러 번 사용됩니다.
    • GL_DYNAMIC_DRAW: 데이터가 많이 변경되고 여러 번 사용됩니다.

삼각형의 position data는 변경되지 않고 많이 사용되며, 모든 렌더링 호출에 대해 동일하게 유지되므로 사용 타입은 GL_STATIC_DRAW가 가장 좋습니다. 예를 들어 자주 변경될만한 데이터를 가지고 있는 버퍼가 있는 경우에는 더 빠른 속도로 write(쓰기)를 허용하는 메모리에 데이터를 배치하도록 하기 위해 GL_DYNAMIC 사용 타입을 사용합니다.

지금 우리는 VBO라고 불리는 vertex buffer object를 통해 관리되는 그래픽 카드의 메모리 내에 vertex data를 저장했습니다. 다음으로 이 데이터를 실제로 처리하는 vertex shader 및 fragment shader를 만들고 싶으므로 이를 구축해 보겠습니다.

vertex input의 요약

  1. 삼각형 렌더링을 위해서 NDC좌표로 총 3개의 정점을 지정한다.
  2. VBO를 통해 GPU 메모리에 정점을을 저장하기 위해 다음 루틴을 따른다.
    1. glGenBuffers로 buffer Id를 하나 생성한다. (vertex buffer object의 buffer type은 GL_ARRAY_BUFFER이다.)
    2. glBindBuffer 함수를 사용하여 새로 생성된 버퍼를 GL_ARRAY_BUFFER로 바인딩 한다.
    3. GL_ARRAY_BUFFER로 바인딩한 시점부터 어떤 GL_ARRAY_BUFFER 버퍼 호출이든 현재 바인딩 되어 있는 VBO를 구성하는데 사용된다. 그렇기에 glBufferData함수를 호출하여 첫 번째 파라미터에 GL_ARRAY_BUFFER를 주게 되면 현재 바인딩되어 있는 VBO를 구성하도록 만드는 것이 가능하다.
    4. glBindBuffer를 호출하여 새로 생성된 VBO를 GL_ARRAY_BUFFER로 바인딩 한 뒤, glBufferData 함수를 통해 사용자 정의 데이터를 현재 바인딩 된 버퍼에 복사하도록 만든다.
  3. 이 다음 단계로 vertex buffer object를 통해 그래픽 카드 내에 vertex data를 저장했으므로 이 데이터를 실제로 처리하는 vertex shader와 fragment shader를 구축한다.

2. Vertex Shader (버텍스 셰이더)

vertex shader는 우리 같은 사람들이 프로그래밍할 수 있는 셰이더 중 하나입니다. 최신 OpenGL에서는 렌더링을 수행하려면 최소한 vertex 및 fragment 셰이더를 설정해야 하므로 셰이더를 간략하게 소개하고 첫 번째 삼각형을 그리기 위한 매우 간단한 2개의 셰이더를 구성합니다. 다음 장에서 우리는 셰이더에 대해 더 자세히 논의할 것입니다.

가장 먼저 해야 할 일은 vertex shader를 셰이더 언어 GLSL(OpenGL Shading Language)로 작성한 다음 이 셰이더를 컴파일하여 애플리케이션에서 사용할 수 있도록 하는 것입니다. 아래에서 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와 유사합니다. 각 셰이더는 버전 선언으로 시작합니다. OpenGL 3.3 이상 GLSL 버전 번호는 OpenGL 버전과 일치합니다(예를 들어 GLSL 버전 420은 OpenGL 버전 4.2에 해당). 또한 core-profile 기능을 사용하고 있다고 명시적으로 언급합니다.

다음으로 in 키워드를 사용하여 vertex shader의 모든 입력 vertex attributes(정점 속성)를 선언합니다. 지금은 위치 데이터에만 관심이 있으므로 단일 vertex attribute만 필요합니다. GLSL에는 접미사 숫자를 기반으로 하는 1~4개의 부동 소수점을 포함하는 벡터 데이터 유형이 있습니다. 각 정점에는 3D 좌표가 있으므로 aPos라는 이름으로 vec3 입력 변수를 만듭니다. 또한 레이아웃(locaiton = 0)을 통해 입력 변수의 위치를 구체적으로 설정했으며 나중에 해당 위치가 필요한 이유를 알게 될 것입니다.

벡터
그래픽 프로그래밍에서 우리는 벡터의 수학적 개념을 아주 자주 사용합니다. 벡터는 모든 공간에서 위치/방향을 깔끔하게 표현하고 유용한 수학적 속성을 가지고 있기 때문입니다. GLSL의 벡터는 최대 크기가 4이고 각각의 값은 각각 공간의 좌표를 나타내는 vec.x, vec.y, vec.z 및 vec.w를 통해 검색할 수 있습니다. vec.w 컴포넌트는 공간에서 position으로 사용되지 않지만(4D가 아닌 3D를 다루고 있음) 원근 분할(perspective division)이라는 것에 사용됩니다. 벡터에 대해서는 이후 장에서 훨씬 더 깊이 다룰 것입니다.

vertex shader의 출력을 설정하려면 position 데이터를 Scenes(장면) 뒤의 사전 정의된 vec4인 gl_Position 변수에 할당해야 합니다. main 함수의 끝에서 우리가 gl_Position을 설정하는 것은 무엇이든 vertex shader의 출력으로 사용될 것입니다. 우리의 입력은 크기가 3인 벡터이므로 이것을 크기가 4인 벡터로 변환해야 합니다. vec4의 생성자 내부에 vec3 값을 삽입하고 w 컴포넌트를 1.0f로 설정하여 이를 수행할 수 있습니다. (다음 장에서 왜 이러는지 설명할 것입니다.)

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

3. Compiling a shader(셰이더 컴파일)

vertex shader의 소스 코드를 가져와서 지금은 코드 파일 맨 위에 있는 const 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로 참조되는 셰이더 객체를 만드는 것입니다. 따라서 vertex shader를 unsigned int로 저장하고 glCreateShader를 사용하여 셰이더를 만듭니다.

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

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

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

glShaderSource 함수는 컴파일할 셰이더 객체를 첫 번째 매개변수로 사용합니다. 두 번째 매개변수는 단 하나인 소스 코드로 전달할 문자열의 수를 지정합니다. 세 번째 매개변수는 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를 컴파일하는 동안 오류가 발견되지 않았으면 이제 컴파일이 완료된 것입니다.

4. Fragment shader

fragment shader는 삼각형을 렌더링하기 위해 만들 두 번째이자 마지막 셰이더입니다. fragment shader는 픽셀의 색상 출력을 계산하는 것입니다. 상황을 편하게 보기 위해서 fragment shader는 항상 주황색을 띤 색상을 출력하게 할 것입니다.


컴퓨터 그래픽스의 색상은 빨강, 초록, 파랑 및 알파(투명도) 컴포넌트의 4가지 값의 배열로 나타납니다. 일반적으로 RGBA라고 줄여서 부릅니다. OpenGL 또는 GLSL에서 색상을 정의할 때 각 컴포넌트의 강도를 0.0에서 1.0 사이의 값으로 설정합니다. 예를 들어 빨간색을 1.0으로 설정하고 녹색을 1.0으로 설정하면 두 색상이 혼합되어 노란색이 됩니다. 이 3가지 색상 구성 요소가 주어지면 1600만 가지 이상의 색상을 생성할 수 있습니다!

#version 330 core
out vec4 FragColor;

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

fragment shader는 하나의 출력 변수만 필요하며 이는 우리가 스스로 계산해야 하는 최종 색상 출력을 정의하는 크기 4의 벡터입니다. out 키워드를 사용하여 출력 값을 선언할 수 있으며 여기에서 즉시 FragColor라는 이름을 지정했습니다. 다음으로 알파 값이 1.0인 주황색으로 vec4를 색상 출력에 할당합니다(1.0은 완전히 불투명함).

fragment shader를 컴파일하는 프로세스는 vertex shader와 유사하지만 이번에는 GL_FRAGMENT_SHADER 상수를 셰이더 유형으로 사용합니다.

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

이제 두 셰이더가 모두 컴파일되고 남은 작업은 두 셰이더 개체를 렌더링에 사용할 수 있는 셰이더 프로그램에 연결하는 것뿐입니다. 여기에서도 컴파일 오류를 확인하십시오!

4.1 Shader program

셰이더 프로그램 객체(shader program object)는 여러 셰이더가 결합된 마지막으로 연결된 버전입니다. 최근에 컴파일된 셰이더를 사용하려면 shader program object에 연결합니다. 그런 다음 객체를 렌더링할 때 이 셰이더 프로그램을 활성화해야 합니다. 활성화된 셰이더 프로그램의 셰이더는 렌더 호출을 실행할 때 사용됩니다.

셰이더를 program에 연결할 때 각 셰이더의 출력을 다음 셰이더의 입력에 연결합니다. 출력과 입력이 일치하지 않는 경우 연결 오류가 발생하는 곳이기도 합니다.

프로그램 개체를 만드는 것은 쉽습니다.

unsigned int shaderProgram;
shaderProgram = glCreateProgram();

glCreateProgram 함수는 프로그램을 생성하고 새로 생성된 program object에 대한 ID 참조를 반환합니다. 이제 이전에 컴파일된 셰이더를 program object에 연결한 다음 glLinkProgram과 연결해야 합니다.

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

코드는 꽤 자명해야 합니다. 셰이더를 프로그램에 연결하고 glLinkProgram을 통해 연결합니다.

셰이더 컴파일에서 셰이더 프로그램 연결이 실패했는지 확인하고 해당 로그를 검색할 수도 있습니다. 그러나 glGetShaderiv 및 glGetShaderInfoLog를 사용하는 대신 이제 다음을 사용합니다.

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

그 결과 glUseProgram을 호출하고 새로 생성된 program object를 매개변수로 사용하여 활성화할 수 있습니다.

glUseProgram(shaderProgram);

glUseProgram 이후의 모든 셰이더 및 렌더링 호출은 이제 이 프로그램 개체(및 따라서 셰이더)를 사용합니다.

glUseProgram함수를 호출한 뒤에 모든 shader와 redering 명령은 이 program object(따라서 셰이더)를 사용하게 됩니다.

아, 그리고 프로그램 개체에 연결한 후에는 셰이더 객체를 삭제하는 것을 잊지 마십시오. 더 이상 필요하지 않습니다.

glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

지금 우리는 입력 vertex data를 GPU로 보내고 vertex shader 및 fragment shader 내에서 vertex data를 처리하는 방법을 GPU에 지시했습니다. 거의 다 왔지만 아직까지는 아닙니다. OpenGL은 아직 메모리의 vertex data를 해석하는 방법과 vertex data를 vertex shader의 속성에 연결(linking)하는 방법을 모릅니다. 우리는 친절하게 OpenGL에게 그 방법을 알려줄 것입니다.

5. vertex attribute 연결하기(Linking Vertex Attributes)

vertex shader를 사용하면 우리가 원하는 vertex attribute의 형태로 원하는 입력을 지정할 수 있습니다. 이는 큰 유연성을 가지게 되지만 입력 데이터의 어느 부분이 vertex shader의 어느 vertex attribute으로 이동하는지 수동으로 지정해야 함을 의미합니다. 즉, 렌더링 전에 OpenGL이 vertex data를 해석하는 방법을 지정해야 합니다.

우리 정점 버퍼 데이터(vertex buffer data)는 다음 형식과 같습니다: vertex_attribute_pointer

  • position 데이터는 정점내부의 각각에 축에 해당하는 값 한 부분당 32bit(4byte) 부동 소수점 값으로 저장됩니다.
  • 각 position은 3개의 값으로 구성됩니다.
  • 3개의 값이 세트인 집합 사이에는 공백(또는 다른 값)이 없습니다. 값은 배열에 빽빽하게 들어 있습니다.
  • 데이터의 첫 번째 값은 버퍼의 시작 부분에 있습니다.

이 지식을 가지고 우리는 OpenGL에게 glVertexAttribPointer를 사용하여 vertex data(vertex attribute 당)를 어떻게 해석해야 하는지 알릴 수 있습니다.

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

함수 glVertexAttribPointer에는 꽤 많은 매개변수가 있으므로 조심스럽게 살펴보겠습니다.

  • 첫 번째 매개변수는 구성하려는 vertex attribute를 지정합니다. 위의 layout (location = 0)이라고 작성한 코드를 가진 vertex shader에서 position vertex attritubte의 위치를 지정했음을 기억하십시오. 이것은 vertex attribute의 위치(location)를 0으로 설정하고, 우리는 데이터를 이 vertex attribute에 전달할 것이기 때문에 0을 전달합니다.
  • 다음 매개변수는 vertex attribute의 크기를 지정합니다. vertex attribute는 vec3이므로 3개의 값으로 구성됩니다.
  • 세 번째 매개변수는 GL_FLOAT(GLSL에서 vec*는 부동 소수점 값으로 구성됨)인 데이터 유형을 지정합니다.
  • 다음 매개변수는 데이터를 정규화할지에 대한 여부를 지정합니다. 만약 우리가 정수 데이터 타입(int, byte)을 입력한다음 이것을 GL_TRUE로 설정했다면, 정수 데이터는 float로 변환 될 때 0(또는 부호 있는 데이터의 경우 -1) 그리고 1로 정규화됩니다. 이것은 우리와 관련이 없으므로 GL_FALSE로 둡니다.
  • 다섯 번째 매개변수는 stride입니다. 연속적인 vertex attribute 사이에 간격(space)을 알려줍니다. 다음 poistion 데이터의 세트는 float 크기의 정확히 3배에 위치하므로 해당 값을 stride로 지정합니다. 배열이 빽빽하게 채워져 있다는 것을 알고 있기 때문에(다음 vertex attribute 값 사이에는 공백이 없음) OpenGL이 stride를 결정하도록 stride를 0으로 지정할 수도 있었습니다(이는 값이 촘촘하게 패킹된 경우에만 작동함). 더 많은 vertex attribute이 있을 때마다 각 vertex attribute 사이의 간격을 신중하게 정의해야 하지만 나중에 더 많은 예를 보게 될 것입니다.
  • 마지막 매개변수는 void* 유형이므로 void*유형으로 이상한 형변환이 필요합니다. 이는 버퍼에서 position data가 시작되는 오프셋입니다. position 데이터는 데이터 배열의 시작 부분에 있으므로 이 값은 0입니다. 이 매개변수는 나중에 자세히 살펴보겠습니다.

각 vertex attribute는 VBO가 관리하는 메모리에서 데이터를 가져오고, 데이터를 가져오는 VBO(여러 VBO를 가질 수 있음)는 glVertexAttribPointer를 호출할 때 현재 GL_ARRAY_BUFFER에 바인딩된 VBO에 의해 결정됩니다. glVertexAttribPointer를 호출하기 전에 이전에 정의된 VBO가 여전히 바인딩되어 있는 상태이기 때문에 vertex attribute 0은 이제 vertex data와 연결됩니다.

  • 위에도 설명되어 있지만, VBO는 vertex buffer objects로 이를 통해 메모리를 관리합니다.
  • VBO를 사용하여 GPU 메모리에 정점들을 저장할 수 있습니다. (GPU메모리에 정점을 저장하는 이유는 접근 속도가 빠르기 때문)
  • 그래서 각 vertex attribute은 VBO를 통해서 메모리에 저장되어 있는 데이터를 가져옵니다.
  • 데이터를 가져오는 VBO가 결정되는 것은 glVertexAttribPointer를 호출할 때 GL_ARRAY_BUFFER에 바인딩된 VBO에 의해 결정됩니다.

이제 OpenGL이 vertex data를 해석하는 방법을 지정했으므로 glEnableVertexAttribArray함수의 매개변수로 vertex attribute location을 제공하고 vertex attribute를 활성화해야 합니다. vertex attribute는 기본적으로 비활성화되어 있습니다. 위의 부분까지 완료하면 모든 설정이 끝났습니다.

  • vertex buffer objects를 사용하여 버퍼의 vertex data를 초기화하고
  • vertex shdaer와 fragment shader를 설정하고
  • OpenGL에게 vertex 데이터를 vertex shader의 vertex attribute에 연결하는 방법을 지정했습니다.

추가해서 설명하자면 glEnableVertexAttribArray함수의 매개변수는 활성화할 vertex attribute의 인덱스를 지정하는 것입니다.

OpenGL에서 객체를 그리는 코드는 다음에 보이는 것과 같습니다:

// 0. OpenGL에서 사용하기 위해 정점 배열을 버퍼에 복사합니다.
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 그런 다음 vertex attributes pointers를 설정합니다.
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
// 2. 객체를 렌더링할 때 셰이더 프로그램(shader program)을 사용합니다.
glUseProgram(shaderProgram);
// 3. 오브젝트를 그립니다.
someOpenGLFunctionThatDrawsOurTriangle();

객체를 그릴 때마다 이 과정을 반복해야 합니다. 그렇게 많아 보이지 않을 수도 있지만, 5개 이상의 vertex attribute와 100개 이상의 다른 object가 있다고 상상해 보십시오(흔한 경우입니다)). 적절한 buffer object를 바인딩하고 각 객체에 대한 모든 vertex attribute을 구성하는 것은 번거로운 프로세스가 됩니다. 이러한 모든 상태 구성을 객체에 저장하고 이 객체를 바인딩하여 상태를 복원할 수 있는 방법이 있다면 어떨까요?

5.1 Vertex Array Object

vertex array object(VAO라고도 함)는 vertex buffer object처럼 바인딩될 수 있으며 바인딩 이후의 모든 vertex attribute 호출은 VAO 내부에 저장됩니다. 이는 vertex attribute 포인터를 구성할 때 호출을 한 번만 수행하면 되고 객체를 그릴 때마다 해당 VAO를 바인딩할 수 있다는 이점이 있습니다. 이것은 다른 VAO를 바인딩하는 것처럼 쉽게 다른 vertex data와 attribute 구성 간의 전환을 가능하게 합니다. 방금 설정한 모든 상태는 VAO 내부에 저장됩니다.

Core OpenGL은 VAO를 사용해야 vertex input(정점 입력)으로 무엇을 해야 하는지 알 수 있습니다. VAO를 바인딩하는 데 실패하면 OpenGL은 draw를 거부할 가능성이 큽니다.

vertex array object(VBO)는 다음을 저장합니다.

  • glEnableVertexAttribArray 또는 glDisableVertexAttribArray에 대한 호출.
  • glVertexAttribPointer를 통한 vertex attritube 구성하기.
  • glVertexAttribPointer에 대한 호출에 의해 vertex attribute와 연관된 vertex buffer object.

함수에 대해 다시 설명 (추가설명)
glEnableVertexAttribArray : 활성화할 vertex attribute의 인덱스를 지정하여 vertex attribute를 활성화시키는 함수이다.
glVertexAttribPointer : OpenGL에게 vertex data를 해석하는 방법을 알려주는 함수

vertex_array_objects

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

VAO를 사용하려면 VAO를 glBindVertexArray를 사용하여 바인딩하기만 하면됩니다. VAO사용을 위해 바인딩한 시점부터 해당 VBO(s) 및 attribute pointer(s)를 바인딩/구성(configure)한 다음 나중에 사용하기 위해 VAO를 바인딩 해제해야 합니다. 객체를 그리려고 하면 그리기 전에 VAO를 기본 설정으로 바인딩하면 됩니다.

코드는 다음과 같습니다.

// ..:: Initialization code (done once (unless your object frequently changes)) :: ..
// 1. bind Vertex Array Object
glBindVertexArray(VAO);
// 2. copy our vertices array in a buffer for OpenGL to use
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. then set our vertex attributes pointers
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

  
[...]

// ..:: Drawing code (in render loop) :: ..
// 4. draw the object
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();  

이게 끝입니다! 우리가 했던 수많은 모든 것들은 지금을 위해 이루어졌던 것입니다. VAO는 vertex attribute를 구성하고 사용할 VBO를 저장합니다. 일반적으로 그릴 객체가 여러 개인 경우 먼저 모든 VAO(및 필요한 VBO 및 속성 포인터)를 생성/구성하고 나중에 사용할 수 있도록 저장합니다. 객체 중 하나를 그리려는 순간 해당 VAO를 가져와 바인딩한 다음 객체를 그리고 VAO를 다시 바인딩 해제합니다.

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

우리가 선택한 객체를 그리기 위해, OpenGL은 우리에게 glDrawArrays 함수를 제공합니다. glDrawArrays함수는 현재 활성화되어있는 셰이더, 이전에 정의된 vertex attribute configuration(정점 속성 구성)과 VBO의 vertex data(VAO를 통해 간접적으로 바인딩 됨)를 사용하여 프리미티브들을 그립니다. 

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

glDrawArrays 함수는 다음과 같은 매개변수를 가집니다.

  • 첫 번째 매개변수로 우리가 그리려는 OpenGL의 primitive type(기본 타입)을 가집니다. 처음에 삼각형을 그리고 싶다고 말했고 거짓말을 하는 것을 좋아하지 않기 때문에 GL_TRIANGLES를 전달합니다.
  • 두 번째 인수는 우리가 그리고 싶은 vertex array의 시작 인덱스를 지정합니다. 우리는 이것을 0으로 둡니다.
  • 마지막 인수는 얼마나 많은 꼭짓점을 그리고 싶은지 지정합니다. 즉, 3입니다(정확히 3개의 꼭짓점 길이인 데이터에서 1개의 삼각형만 렌더링합니다).

이제 코드를 컴파일하고 오류가 발생하면 거꾸로 작업하십시오. 애플리케이션이 컴파일되자마자 다음 결과가 표시되어야 합니다.

image

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

출력이 동일하지 않은 경우 아마도 잘못된 작업을 수행했을 수 있으므로 전체 소스 코드를 확인하고 놓친 부분이 있는지 확인하십시오.

— 이후로 번역 안 다듬어져 있음. —

6. Element Buffer Objects

정점을 렌더링할 때 마지막으로 논의하고 싶은 것이 있습니다. 바로 EBO로 축약된 element buffer object입니다. element buffer object가 작동하는 방식을 설명하려면 예를 드는 것이 가장 좋습니다. 삼각형 대신 사각형을 그린다고 가정해 보겠습니다. 두 개의 삼각형을 사용하여 직사각형을 그릴 수 있습니다(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%의 오버헤드입니다. 이것은 1000개 이상의 삼각형이 있는 더 복잡한 모델이 있는 즉시 더 나빠질 것입니다. 겹친다.

더 나은 솔루션은 고유한 정점만 저장한 다음 이러한 정점을 그릴 순서를 지정하는 것입니다. 이 경우 직사각형에 대해 4개의 정점만 저장하고 다음 순서를 지정하면 됩니다. 우리는 그것들을 그리고 싶습니다. OpenGL이 우리에게 그런 기능을 제공한다면 좋지 않을까요?

고맙게도 요소 버퍼 객체는 정확히 그렇게 작동합니다. EBO는 정점 버퍼 객체와 마찬가지로 OpenGL이 그릴 정점을 결정하는 데 사용하는 인덱스를 저장하는 버퍼입니다. 이른바 인덱스 드로잉이 바로 우리 문제의 솔루션입니다. 시작하려면 먼저 (고유한) 꼭짓점과 인덱스를 지정하여 사각형으로 그려야 합니다.

  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도 자동으로 바인딩됩니다.

vertex_array_objects_ebo

주의
VAO는 대상이 GL_ELEMENT_ARRAY_BUFFER일 때 glBindBuffer 호출을 저장합니다. 이것은 또한 바인딩 해제 호출을 저장한다는 것을 의미하므로 VAO 바인딩을 해제하기 전에 요소 배열 버퍼의 바인딩을 해제하지 마십시오. 그렇지 않으면 EBO가 구성되지 않습니다.

결과 초기화 및 그리기 코드는 이제 다음과 같습니다.

// ..:: Initialization code :: ..
// 1. bind Vertex Array Object
glBindVertexArray(VAO);
// 2. copy our vertices array in a vertex buffer for OpenGL to use
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. copy our index array in a element buffer for OpenGL to use
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. then set the vertex attributes pointers
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  

[...]
  
// ..:: Drawing code (in render loop) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
glBindVertexArray(0);

프로그램을 실행하면 아래와 같은 이미지가 표시됩니다. 왼쪽 이미지는 친숙해 보여야 하고 오른쪽 이미지는 와이어프레임 모드에서 그린 직사각형입니다. 와이어프레임 직사각형은 직사각형이 실제로 두 개의 삼각형으로 구성되어 있음을 보여줍니다.

image image