[WebGL] WebGL #4 – WebGL 작동 방법의 이해 1

개요

지금까지 3편의 기초 내용을 통해 WebGL에 대해서 감각을 익혀봤습니다. 기초편에 나오는 내용과 단어는 그 의미를 정확하게 알고 있어야 계속 연재되는 글을 읽는데 문제없을 것 같습니다.

기초편에서는 전체적으로 WebGL 개발을 어떻게 시작할 수 있는지 익히는데 초점을 두었지만 실제 WebGL과 GPU가 어떻게 동작하는지에 대해서는 자세히 다루지 않았습니다. 이번 글에서는 WebGL 동작 방법에 대해 자세히 학습해 보겠습니다. 이 글을 통해서 WebGL에 대해 더욱 알아가는 계기가 되었으면 합니다.

기본적인 내용은 WebGLFundamentals.org 에서 다루는 내용을 나름대로 공부한 내용을 정리한 글임을 미리 알려드립니다.

셰이더 작동 방법의 이해

GPU는 두가지 일을 합니다. 첫째는 원본의 정점(또는 데이터 스트림)을 클립 공간(clip space) 정점으로 처리합니다. 둘째는 첫 번째를 기반으로 픽셀(Pixel)처리를 합니다. 버텍스 셰이더(Vertex shader)와 프래그먼트 셰이더(Fragment shader)는 GPU가 이러한 일들을 할 수 있도록 구체적인 명령과 계산된 데이타를 전달하는 기능을 담당합니다.

먼저 기본적인 셰이더 작동 방법을 이해해 봅시다. 버텍스 셰이더를 아래처럼 작성합니다.

attribute vec4 a_position;
uniform mat4 u_matrix; //4x4 변환 행렬 유니폼
void main() {
	gl_Position = u_matrix * a_position;
}

프래그먼트 셰이더는 아래처럼 고정된 색상을 반영하도록 작성합니다.

precision mediump float;
void main() {
	gl_FragColor = vec4(1, 0, 0.5, 1); // 붉은 보라색을 반환합니다.
}

그리고 WebGL API를 통해 아래 코드처럼 gl.drawArrays() 함수를 호출했다고 가정해 봅니다.

var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 9;
gl.drawArrays(primitiveType, offset, count);

여기서 count = 9는 9개의 정점을 처리한다는 것을 의미합니다. 아래 그림은 이 버텍스 셰이더가 어떻게 동작하는지 알기 쉽게 시각적으로 설명해주고 있습니다.

출처 : webglfundamentals.org

위 그림에서 왼쪽에는 GPU의 버퍼에 제공된 원본 정점(vertext) 데이터 9개가 있습니다. 그림의 중앙에 버텍스 셰이더 함수는 앞서 GLSL로 작성했죠. 이 함수는 호출될 때마다 각각의 정점을 하나씩 가져옵니다. 가져온 정점은 수학적 처리해서 만들어진 가공된 정점을 GLSL에서 정의된 특수 변수인 gl_Position에 주게 되면 오른쪽 클립 공간의 정점으로 GPU에 저장하게 됩니다.

gl.drawArrays() 함수를 호출할 때 TRIANGLES을 프리미티브 타입(Primitive Type)으로 지정했기 때문에 3개의 클립 공간의 정점이 생성될 때마다 GPU는 이를 이용해 삼각형을 그릴 겁니다. 이는 삼각형을 구성하는 3개의 클립 공간의 정점으로 스크린 공간(screen space)의 픽셀 단위의 좌표값으로 래스터화(rasterization)한다고 볼 수 있습니다. 여기서 래스터화란, 벡터 단위 정보를 픽셀 단위의 비트맵 정보로 변환하는 과정을 뜻합니다. 간단하게 이 과정을 ‘삼각형을 픽셀로 그린다’라고 생각하면 되겠네요. 래스터화를 할 때 삼각형을 구성하는 각각의 픽셀에 대해서 프래그먼트 셰이더를 호출하게 됩니다. 이 때, 프래그먼트 셰이더는 특수변수인 gl_FragColor를 통해 각 픽셀의 색상을 알려주게 됩니다. 다만, 우리는 모든 정점의 색이 동일한 색으로만 제공하는 프래그먼트 셰이더를 만들었기 때문에 삼각형을 이루는 픽셀들은 전부 하나의 색으로 처리되게 됩니다.

이제 대략 어떻게 셰이더가 동작하는지 감이 오시나요?

색 보정 처리 방법의 이해

지금까지는 삼각형을 래스터화를 할 때, 각 픽셀에 필요한 색을 하나의 색으로 강제했습니다. 그러다보니 프래그먼트 셰이더에서 색이 어떤 방법으로 픽셀 단위로 지정되는지 정확히 이해하기 힘듭니다. 그래서 이를 이해하기 위해 조금 다른 방식의 예제를 만들어 보겠습니다.

이번에는 프래그먼트 셰이더를 정점의 위치를 기반으로 색상을 줄 수 있도록 코딩해 보겠습니다. 각각의 셰이더를 다음 처럼 만들어 보겠습니다. 기존에 작성된 셰이더와 차이점을 비교해 보세요.

아래는 버텍스 셰이더입니다.

//위치 데이타를 다루는 애트리뷰트 
attribute vec4 a_position;

//변환 행렬 유니폼  
uniform mat4 u_matrix;

//각 위치에 대한 색값을 가지는 배어링  
//프래그먼트 셰이더로 전달됨 
varying vec4 v_color;

void main() {
	//변환행렬에 위치를 곱한다.
	gl_Position = u_matrix * a_position;
	
	//클립공간을 색공간으로 변환
	//클립공간은 -1.0 ~ 1.0의 범위인데,
	//이것을 0.0 ~ 1.0의 색공간 값으로 변경
	v_color = gl_Position * 0.5 + 0.5;
}

이번에는 프래그먼트 셰이더입니다.

precision mediump float;

//버텍스 셰이더에서 받은 색값 
varying vec4 v_color;

void main() {
	gl_FragColor = v_color;
}

자, 두 셰이더에서 지금까지 예제에서 보지 못했던 배어링(varying)이 도입되었습니다. 배어링도 애트리뷰트, 버퍼, 유니폼, 텍스쳐과 같이 셰이더에 데이터를 공급하는 방법 중에 하나입니다. 다만, 배어링은 WebGL API로 데이터를 공급해주는 것이 아닌 버텍스 셰이더에서 만든 데이타를 프래그먼트 셰이더에게 전달하는데 쓰입니다.

버텍스 셰이더에서 작성된 배어링과 프래그먼트 셰이더의 배어링이 같은 이름과 타입을 가져야 연결이 된다는 점을 유념하세요.

버텍스 셰이더에서 배어링으로 지정된 v_color를 보면 gl_Position에 지정된 클립 공간 좌표 값을 이용해 0.0 ~ 1.0 범위의 색 공간 값으로 변환해서 설정되고 있습니다. 프래그먼트 셰이더의 배어링인 v_color는 버텍스 셰이더에서 받은 색상 값을 참고해서 gl_FragColor로 전달되는 것 처럼 보입니다(코드만 보면 마치 삼각형 1개를 만들면 프래그먼트 셰이더가 3번 호출될 것으로 보이죠? 하지만 사실 그렇지 않습니다. 마저 보시면서 이해하시죠).

작성한 셰이더를 동작시키기 위해 삼각형 1개를 그리는 WebGL API를 만들어 봅시다.

아래는 GPU에 위치 데이타를 공급합니다. 클립 공간이 -1 ~ 1 범위 이므로 그 안에 삼각형을 그리기 위해 삼각형의 각 정점의 x, y 좌표값을 만들었음을 확인하세요.

//위치 데이타를 GPU 버퍼에 공급 (삼각형 1개임) 
var positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
var positions = [
   0,    0.7,
  -0.7, -0.7,
   0.7, -0.7
];
gl.bufferData(
	gl.ARRAY_BUFFER, 
	new Float32Array(positions), 
	gl.STATIC_DRAW
);

그리고 버텍스 셰이더의 유니폼인 u_matrix의 위치를 참조하고 데이타를 아래처럼 공급할 수 있습니다.

var matrixUniformLocation = 
   gl.getUniformLocation(program, "u_matrix");   

//프로그램에 변환행렬 설정(아래는 단위 행렬로 아무 변환 안함)
gl.uniformMatrix4fv(matrixUniformLocation, false, [
	1, 0, 0, 0,
	0, 1, 0, 0,
	0, 0, 1, 0,
	0, 0, 0, 1
]);

위 코드에서 변환행렬에는 단위행렬값만 배치했으므로 클립 공간으로 변환되는 좌표는 원본의 좌표와 같은 값이 될 것입니다.

위치 애트리뷰트가 GPU의 버퍼에 등록된 삼각형 정점 데이타를 어떻게 참조하는지 아래 코드처럼 셋팅해줍니다. 즉, size가 2이므로 2개씩 꺼내갑니다. 결국, 버텍스 셰이더에 a_position의 타입이 vec4이므로 x, y, z, w값중 x, y만 셋팅되고 z, w값은 기본 0이 됩니다.

//GPU에 등록된 버퍼의 위치 데이타를 
//어떻게 위치 애트리뷰트 참조하는지 셋팅 
gl.enableVertexAttribArray(positionAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
var size = 2;           
var type = gl.FLOAT;  
var normalize = false;
var stride = 0; 
var offset = 0;
gl.vertexAttribPointer(
	positionAttributeLocation, 
	size, 
	type, 
	normalize, 
	stride, 
	offset
);

렌더링을 실시합니다. 아래 코드에서 count는 결국 3입니다. 왜냐하면 positions는 앞선 코드에서 x, y좌표 3쌍을 가지는 배열이기 때문입니다. 또한 여기서 primitive 타입을 TRIANGLES로 했으므로 3개의 정점을 가지는 삼각형이 그려질 겁니다.

//렌더링 실시 
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = positions.length / 2; 
gl.drawArrays(primitiveType, offset, count);

결과는 다음과 같습니다.

이제 왜 이런 결과가 나왔는지 알아보겠습니다. 우리는 버텍스 셰이더에서 3개의 정점만 가지는 삼각형을 만들면서 동시에 각 정점의 3개의 색만 계산했습니다. 하지만 삼각형의 면에는 다양한 색으로 칠해졌지요.

이렇게 된 이유는 다음과 같습니다. 버텍스 셰이더의 배어링에는 삼각형을 그리는 경우 3번 정점 색이 설정될 것입니다. 이 때 래스터화하는 단계로 진입하게 되고 삼각형을 구성하는 픽셀들이 정해집니다. 각각의 픽셀 수 만큼 각 픽셀의 색 값을 요청하기 위해 프래그먼트 셰이더가 호출됩니다. 이 때 각 픽셀마다 버텍스 셰이더의 배어링에 설정된 3개의 정점 색을 3점 보간(interpolation)해서 프래그먼트 셰이더의 배어링에 전달됩니다. 이 때 프래그먼트 셰이더는 gl_FragColor = v_color 코드로 인해 색이 그대로 반환하게 되면서 픽셀의 색이 결정됩니다. 이 과정을 통해 위 그림처럼 보간된 색으로 칠해진 삼각형의 모습을 볼 수 있는 겁니다.

여기서 알아야 할 점은 다음과 같습니다.

  • 프래그먼트 셰이더는 래스터화된 점, 선, 면 등을 구성하는 픽셀 수 만큼 호출된다.
  • 버텍스 셰이더에서 설정한 배어링 값은 선의 경우 2점 보간, 삼각형의 경우 3점 보간을 해서 프래그먼트 셰이더에게 전달된다(배어링은 보간되어 전달된다는 점이 중요합니다).

아래 그림은 배어링을 통해 전달된 v_color가 어떻게 보간되어 삼각형의 픽셀의 색을 채우게 되는지 시각적으로 설명해 줍니다.



출처 : webglfundamentals.org

말보다 그림이 훨씬 이해하기 좋네요. ^^

셰이더에 데이터를 전달하는 방법

우리는 현재 글과 기초편을 통해 셰이더에서 데이터를 전달하는 방법 4가지중 3가지를 다뤘습니다. 잠깐 복습하자면 다음과 같습니다.

  • 애트리뷰트(Attributes)와 버퍼(Buffers) : 버퍼를 통해 데이터를 GPU에 공급하고, 애트리뷰트는 셰이더에서 이 데이터를 정해진 순서대로 가져와 사용됩니다. 우리는 위치 정보만 전달하는 예시만 들었으나 법선(normals), 텍스쳐 좌표, 색등 다양한 정보를 전달할 수 있습니다.
  • 유니폼(Uniforms) : 셰이더에서 사용하는 전역변수입니다. 버텍스 셰이더, 프래그먼트 셰이더에서 전부 사용될 수 있습니다. 이 값은 WebGL API를 통해 직접 지정할 수 있고, 한번의 렌더링에 일정하게 사용할 값을 지정할 때 사용합니다.
  • 배어링(varing) : 버텍스 셰이더에서 프래그먼트 셰이더로 데이타를 제공할 때 사용합니다. 이 글의 예제에서 버텍스 셰이더에서 공급한 배어링 값은 픽셀 단위로 보간처리 되어 프래그먼트 셰이더에 전달된다는 사실을 공부했습니다.

우리는 텍스쳐(Texture)만 다루지 않았습니다. 텍스쳐는 무작위로 접근이 가능한 배열로된 데이타로 WebGL API로 전달할 수 있습니다. 보통 이미지 데이타로 생각할 수 있지만 텍스쳐 자체는 단순한 데이타 이므로 색상뿐 아니라 정의하기에 따라 다른 데이타를 얼마든지 제공할 수 있는 것이 특징입니다. 앞으로 텍스쳐도 다룰 예정입니다.

예제 전체 코드

이 예제의 전체 코드는 다음과 같습니다.

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGL #4 - WebGL 작동 방법의 이해 #1</title>
</head>
<body>
<canvas id="c" width="500" height="500"></canvas>
<script id="2d-vertex-shader" type="notjs">
//위치 데이타를 다루는 애트리뷰트 
attribute vec4 a_position;

//변환 행렬 유니폼  
uniform mat4 u_matrix;

//각 위치에 대한 색값을 가지는 배어링  
//프래그먼트 셰이더로 전달됨 
varying vec4 v_color;

void main() {
	//변환행렬에 위치를 곱한다.
	gl_Position = u_matrix * a_position;
	
	//클립공간을 색공간으로 변환
	//클립공간은 -1.0 ~ 1.0의 범위인데,
	//이것을 0.0 ~ 1.0의 색공간 값으로 변경
	v_color = gl_Position * 0.5 + 0.5;
}
</script>
<script id="2d-fragment-shader" type="notjs">
precision mediump float;

//버텍스 셰이더에서 받은 색값 
varying vec4 v_color;

void main() {
	gl_FragColor = v_color;
}
</script> 
<script>
//WebGL 컨텍스트 생성 
var canvas = document.getElementById("c");
var gl = canvas.getContext("webgl");
if (!gl) {
    console.log('no webgl for you!');
}

//셰이더 생성(버텍스, 프래그먼트)
function createShader(gl, type, source) {
	var shader = gl.createShader(type);
	gl.shaderSource(shader, source);
	gl.compileShader(shader);
	var success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
	if(success){
		return shader;
	}
	console.log(gl.getShaderInfoLog(shader));
	gl.deleteShader(shader);
}
var vertexShaderSource = document.getElementById("2d-vertex-shader").text;
var fragmentShaderSource = document.getElementById("2d-fragment-shader").text;
var vertexShader = createShader(
	gl, gl.VERTEX_SHADER, vertexShaderSource
);
var fragmentShader = createShader(
	gl, gl.FRAGMENT_SHADER, fragmentShaderSource
);

//2개의 셰이더로 프로그램 1개 생성 
function createProgram(gl, vertexShader, fragmentShader) {
	var program = gl.createProgram();
	gl.attachShader(program, vertexShader);
	gl.attachShader(program, fragmentShader);
	gl.linkProgram(program);
	var success = gl.getProgramParameter(
		program, gl.LINK_STATUS
	);
	if(success){
		return program;
	}
	console.log(gl.getProgramInfoLog(program));
	gl.deleteProgram(program);
}
var program = createProgram(
	gl, vertexShader, fragmentShader
);

//위치 데이타를 GPU 버퍼에 공급 (삼각형 1개임) 
var positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
var positions = [
   0,    0.7,
  -0.7, -0.7,
   0.7, -0.7
];
gl.bufferData(
	gl.ARRAY_BUFFER, 
	new Float32Array(positions), 
	gl.STATIC_DRAW
);

//애트리뷰드와 유니폼 위치 가져오기 
var positionAttributeLocation = 
   gl.getAttribLocation(program, "a_position");
var matrixUniformLocation = 
   gl.getUniformLocation(program, "u_matrix");   

//뷰포트 설정 
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

//화면 clear
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);

//프로그램 사용 실시 
gl.useProgram(program);

//GPU에 등록된 버퍼의 위치 데이타를 
//어떻게 위치 애트리뷰트 참조하는지 셋팅 
gl.enableVertexAttribArray(positionAttributeLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
var size = 2;           
var type = gl.FLOAT;  
var normalize = false;
var stride = 0; 
var offset = 0;
gl.vertexAttribPointer(
	positionAttributeLocation, 
	size, 
	type, 
	normalize, 
	stride, 
	offset
);

//프로그램에 변환행렬 설정(아래는 단위 행렬로 아무 변환 안함)
gl.uniformMatrix4fv(matrixUniformLocation, false, [
	1, 0, 0, 0,
	0, 1, 0, 0,
	0, 0, 1, 0,
	0, 0, 0, 1
]);

//렌더링 실시 
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = positions.length / 2; 
gl.drawArrays(primitiveType, offset, count);
</script>
</body>
</html>

결론

WebGL이 어떻게 동작하는지 간단히 살펴봤습니다. 버텍스 셰이더와 프래그먼트 셰이더의 동작 방식을 조금 더 이해할 수 있었습니다. 또한 이 과정에서 각 셰이더간 정보를 전달하기 위해 배어링(varying)을 쓰는 방법도 살펴보았습니다.

이 글은 계속 연재됩니다. 초보자의 경우 순서대로 보셔야 이해가 가능합니다. 그러므로 자신이 WebGL에 대해서 잘 모른다고 판단하시면 WebGL #1 – 기초 1부터 차근차근 읽어보시는 것을 추천합니다.

다음 글에는 WebGL 작동 방법을 더 자세히 이해할 수 있는 설명과 예시를 정리할 것입니다.

이전글 : [WebGL] WebGL #3 – 기초 3
다음글 : [WebGL] WebGL #5 – WebGL 작동 방법의 이해 2