WebGL 튜토리얼 목록

이 튜토리얼에서 설명하는 개념은 WebGL 뿐이 아니라 OpenGL 등 다른 그래픽스 라이브러리에도 일반적으로 적용되는 내용입니다.

이 튜토리얼에서는 화면에 하얀색 직사각형을 하나 그려 보도록 하겠습니다.

개요

그래픽스 라이브러리의 API는 크게 세 종류로 나누어져 있습니다.

  • GPU에 데이터를 전송하는 API
  • 라이브러리의 설정을 변경하는 API
  • GPU에 작업을 명령하는 API

3가지 종류의 API를 사용하여 보통 다음과 같은 순서로 작업이 이루어집니다.

  1. 화면에 표시할 물체의 데이터(3D 모델, 이미지 등)를 GPU에 전송한다.
  2. GPU에 담긴 여러 데이터 중 어떤 데이터를 사용할지 설정한다.
  3. 화면에 그린다.

여기에서 1, 2번 작업과 3번 작업이 나누어져 있다는 것이 핵심입니다. 코드로 그림을 그린다고 할 때, 보통의 경우 draw(image) 형태의 함수를 상상합니다. 그러나, 이런 식으로 설계할 경우 먼저 CPU에서 데이터를 GPU에 전송하고, 다음으로 GPU가 그림을 화면에 표시합니다. draw 콜이 여러 번 반복되면 CPU와 GPU가 서로의 작업을 기다리게 되어 병목 현상이 발생합니다.

이러한 사태를 방지하기 위해 데이터를 전송하는 API와 실제로 그리는 작업을 명령하는 API를 분리했습니다. 데이터 전송은 필요한 경우에 비동기적으로 진행하고, 빠르게 돌아가야 하는 render loop가 계속해서 작업을 명령할 수 있도록 설계하는 것이 바람직합니다.

Vertex, Vertex Attributes, 모델

  • Vertex: 모든 도형은 그 표면을 이루는 꼭짓점(vertex)들과 두 꼭짓점이 연결된 변(edge), 그리고 변들로 이루어진 면(face)으로 이루어져 있습니다. 컴퓨터 그래픽스에서는 도형을 vertex들과 vertex가 서로 연결된 face로 표현합니다.
  • Vertex Attributes: 각 vertex가 가지고 있는 성질(attribute)를 의미합니다. 대표적인 attriubute로는 위치, 색깔, normal(방향)가 있습니다.
  • 모델(Model): Vertex, edge, face로 이루어진 도형을 모델이라고 합니다.

Buffer Object

OpenGL Wiki - Buffer Object

모델에 속한 vertex의 데이터를 GPU에 전송하기 위해서는 어떻게 해야 할까요? Buffer Object(BO)는 GPU에 보낼 수 있는 여러 가지 형식의 데이터 중 하나입니다. BO는 특별한 규칙이 없는 배열입니다. 다른 함수들을 통해 이 배열을 이용하여 실제로 어떻게 작업을 수행할지 결정할 수 있습니다. 이 부분에 관해서는 뒤에서 더 설명하도록 하겠습니다.

WebGL의 다음 API들을 통해 BO를 관리합니다.

  • WebGLBuffer gl.createBuffer()

    새로운 BO를 생성하여 반환합니다.

  • void gl.bindBuffer(GLenum target, WebGLBuffer buffer)

    설정을 변경하여 인자로 넘겨준 BO를 bind 합니다. 앞서 설명한 API의 종류 중 “어떤 데이터를 사용할지” 설정하는 함수들이 있다고 했는데, 어떤 데이터를 bind한다는 뜻은 GPU가 그 데이터를 사용하도록 설정한다는 뜻입니다.

    target은 bind할 BO를 어떻게 사용할지 지정합니다. 이 튜토리얼에서는 gl.ARRAY_BUFFER로 설정합니다. (이 설정에 관해서는 다음 튜토리얼에서 자세히 다루도록 하겠습니다.)

  • void gl.bufferData(GLenum target, ArrayBuffer data, GLenum usage)

    data에 들어 있는 데이터를 현재 bind된 BO로 전송합니다. target은 앞서 설명한 bindBuffer 함수와 동일합니다. usage는 GPU에게 이 데이터를 어떻게 사용할 것이라는 힌트를 주는 것인데, 힌트일 뿐이라 꼭 그렇게 사용해야 하는 것은 아닙니다. 이 튜토리얼에서는 “이 BO에 단 한 번만 데이터를 쓰고(STATIC), 데이터를 쓰기만 하고 읽지는 않을 것(DRAW)”라는 의미를 가진 gl.STATIC_DRAW로 설정합니다.

  • void gl.deleteBuffer(WebGLBuffer buffer)

    인자로 넘겨준 BO를 삭제합니다.

BO는 다양한 용도로 이용할 수 있습니다. 예를 들어, 우리가 화면에 표시할 모델의 vertex 위치들을 저장하는 데 사용할 수 있습니다.

직사각형을 BO에 저장하기

API들을 사용해서 우리가 화면에 그릴 직사각형의 데이터를 BO에 저장해 봅시다. 앞 튜토리얼의 코드에서 계속 진행합니다.

지금 src/main.ts의 코드는 크게 두 부분으로 나누어져 있습니다. 화면에 무언가를 그리기 전 전처리를 하는 부분과, 실제로 화면에 표시하는 부분입니다. (물론 지금은 그리지는 않고, 화면을 초기화하기만 했습니다.)

/* 초기화 부분 */
global.set('gl', gl);

gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

/* 그리는 부분 */
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);

초기화 부분에서 코드를 추가하여 새로운 BO를 만든 뒤, 직사각형의 데이터를 BO에 저장할 것입니다. 먼저 전송할 데이터를 담은 ArrayBuffer를 만들도록 하겠습니다.

직사각형은 4개의 꼭짓점으로 이루어져 있지만, 많은 그래픽 라이브러리는 사각형 이상의 다각형을 처리하지 않습니다. 대신, 다각형을 삼각분할(triangulate)하여 여러 개의 삼각형으로 나누어 처리합니다. 즉, 직사각형을 나누어 2개의 삼각형으로 만들어 6개의 꼭짓점의 위치를 BO에 담도록 하겠습니다.

...
/* 데이터 버퍼에 담기 */
const data = new Float32Array([
    -0.5,  0.5, // 왼쪽 위
    -0.5, -0.5, // 왼쪽 아래
     0.5, -0.5, // 오른쪽 아래
    -0.5,  0.5, // 왼쪽 위
     0.5, -0.5, // 오른쪽 아래
     0.5,  0.5  // 오른쪽 위
]);

/* 새로운 BO 생성하고 bind */
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);

/* 데이터 BO에 전송 */
gl.bufferData(gl.ARRAY_BUFFER, data.buffer, gl.STATIC_DRAW);

/* 전송 후 bind 풀기 */
gl.bindBuffer(gl.ARRAY_BUFFER, null);
...

데이터에서 1~3번째 꼭짓점으로 첫 번째 삼각형을, 4~6번째 꼭짓점으로 두 번째 삼각형을 나타냅니다. 반드시 반시계방향으로 꼭짓점을 저장해야 한다는 사실에 유의하세요.

Vertex Array Object

OpenGL Wiki - Vertex Specification

VAO는 WebGL 2.0에 새로 적용된 기능입니다. 1.0 버전에서는 extension을 적용해야 사용할 수 있습니다.

Vertex Array Object(VAO)는 여러 개의 vertex attribute array를 가지고 있습니다. 각 vertex attribute array가 위치 목록, 색깔 목록 등을 담당하고, 이들을 모아 하나의 모델을 이루는 방식으로 이용합니다. 즉, VAO를 추상화한 것이 모델입니다.

VAO 또한 BO와 비슷한 API들로 생성, bind, 삭제할 수 있습니다.

  • WebGLVertexArrayObject gl.createVertexArray()
  • void gl.bindVertexArray(WebGLVertexArrayObject vao)
  • void gl.deleteVertexArray()

하지만 VAO 자체는 데이터를 가지고 있지 않습니다. 대신, 자신이 가진 각 vertex attribute array마다 그 array가 사용할 BO와 사용할 규칙을 설정할 수 있습니다. 이 함수가 실행되면, 현재 bind된 VAO가 현재 bind된 BO로의 reference를 저장합니다.

  • void gl.vertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, GLintptr offset)
    • index는 이 함수로 설정할 vertex attribute array의 index입니다. VAO는 gl.MAX_VERTEX_ATTRIBS개의 attribute array를 사용할 수 있고, 0번부터 gl.MAX_VERTEX_ATTRIBS - 1번까지 index가 지정되어 있습니다.
    • type: BO는 타입이 지정되지 않은 배열입니다. 우리가 short로 넣든 float로 넣든, BO의 입장에서는 단순한 binary data의 나열일 뿐입니다. type 인자를 통해 BO의 데이터를 어떤 타입으로 해석해야 할지 설정합니다.
    • normalizedtrue로 지정하면, type 인자에서 short 등 정수 타입을 설정했을 때 데이터가 [-1, 1] 사이의 실수로 변환되어 해석됩니다.
    • size, stride, offset: BO는 1차원 배열이기 때문에, 각 vertex가 데이터를 몇 byte씩 차지하고(stride), 이 중 이 attribute가 어디서부터(offset), 몇 개를 쓸지(size) 설정해주는 인자들입니다.

      예시 1. 위에서 직사각형의 6개 vertex를 저장할 때 6*2 = 12개의 float 데이터(48 byte)를 BO에 저장했습니다. 각 vertex가 2개 float = 8 byte를 차지하고, 이 중 ‘위치’라는 vertex attribute가 8 byte 중 0 byte부터 float 2개를 차지합니다. 따라서 size=2, stride=8, offset=0 이 됩니다.

      예시 2. 각 vertex가 위치 뿐만이 아니라 (r, g, b) 3개의 float 데이터로 이루어진 색깔 또한 가지고 있다고 합시다. 이 경우 6*(2 + 3) = 30개의 데이터(120 byte)를 BO에 저장하게 됩니다. Vertex마다 20 byte를 사용하고, 이 중 ‘위치’는 0 byte부터 float 2개를, ‘색깔’은 8 byte부터 float 3개를 사용합니다. 따라서 ‘위치’를 설정할 때는 size=2, stride=20, offset=0, ‘색깔’을 설정할 때는 size=3, stride=20, offset=8 이 됩니다.

size, stride, offset 인자는 한 BO가 여러 개의 vertex attribute에 관한 데이터를 담고 있을 때 사용합니다. 그러면 그냥 한 BO에 필요한 데이터를 모두 담지, 왜 VAO가 여러 개의 BO를 가질 필요가 있을까요? BO의 일부 데이터만 수정하는 것이 비효율적이기 때문입니다. 따라서, 어떤 데이터는 자주 변경되고, 어떤 데이터는 변하지 않는다면 두 데이터를 다른 BO에 담아 변경되는 BO만 갱신할 수 있습니다.

마지막으로, 이 API를 통해 VAO가 사용할 vertex attribute array를 지정할 수 있습니다. 처음에는 모두 disable된 상태이기 때문에 enableVertexAttribArray를 통해 enable해 주어야 합니다.

  • void gl.enableVertexAttribArray(GLuint index)
  • void gl.disableVertexAttribArray(GLuint index)

BO와 VAO 연결하기

이제 다시 코드로 돌아가겠습니다. BO에 데이터를 전송한 후, 새로운 VAO를 만들고 VAO의 0번 vertex attribute가 이 BO의 데이터를 사용하도록 설정합니다.

src/main.ts에 계속해서 다음 코드를 작성하세요.

/* BO 생성, 데이터 전송 */
...
/* VAO 생성 및 bind */
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);

/* BO bind */
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);

/* Vertex Attrib Array 설정 */
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 2, 0);
gl.enableVertexAttribArray(0);

/* 설정 후 bind 풀기 */
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
...

이제 모든 데이터를 GPU에 전송했고, 이 데이터를 화면에 직사각형을 그릴 준비가 끝났습니다. 그런데… 무엇을 그릴지는 GPU에 알려줬는데, 어떻게 그릴지는 아직 알려주지 않았네요.

Shader 작성

지금은 그냥 아래 코드를 그대로 사용하도록 하겠습니다. src/main.ts에서 VAO를 설정한 후, 다음 코드를 작성하세요.

...
/* BO 생성, 데이터 전송 */
/* VAO 생성, 데이터 전송 */

/* 지금은 몰라도 되는 코드 */
const vertexShaderSource = 
`#version 300 es
layout(location = 0) in vec2 position;
void main() {
    gl_Position = vec4(position, 0, 1);
}
`
const fragmentShaderSource = 
`#version 300 es
precision mediump float;
out vec4 out_color;
void main() {
    out_color = vec4(1, 1, 1, 1);
}
`
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);

gl.useProgram(program);
...

이 코드가 실제로 무슨 일을 하는지는 이후 튜토리얼에서 자세히 다루도록 하겠습니다.

어떻게 그리지? - [WebGL] 04-1. 그래픽스 파이프라인, Shader, GLSL

직사각형 그리기

다음 API를 사용하면 GPU가 현재 bind된 VAO설정한 shader program을 사용하여 화면에 렌더링합니다.

  • void gl.drawArrays(GLenum mode, GLint first, GLsizei count)

mode는 vertex간의 관계를 어떻게 해석할지 지정하는 인자입니다. 우리는 모든 모델을 삼각형으로 표현하고 있기 때문에, vertex를 3개씩 묶어서 삼각형으로 해석하라는 의미의 gl.TRIANGLES를 사용합니다. firstcount는 vertex 중 몇 번째 vertex부터 몇 개를 그릴지 지정하는 인자입니다. 모든 vertex를 사용하고, 직사각형에 속한 vertex가 6개이기 때문에 first=0, count=6으로 전달하면 되겠습니다.

src/main.ts에서 그리는 부분 코드에 다음 내용을 작성합니다.

/* 초기화 부분 */
...

/* 그리는 부분 */
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);

gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);

이제 프로젝트를 webpack으로 빌드하고, http-server로 결과물을 실행하면 다음과 같이 하얀색 직사각형이 화면에 그려집니다.

Preview

VBO에 전달하는 좌표를 바꿔 보고, 의도한 대로 사각형의 모양이 변하는지 확인해 보세요.

링크

GitHub Repository