튜토리얼 GLSL 입문

GLSL 입문

By Dave Pagurek, Austin Lee Slominski, Adam Ferriss

현대의 컴퓨터에는 GPU(Graphics Processing Unit)라는 특별한 하드웨어가 들어 있습니다. 셰이더는 이 GPU에서 실행되는 특수한 프로그램으로, 놀라운 것들을 해낼 수 있습니다. 셰이더는 GPU를 활용해 많은 픽셀을 한 번에 병렬로 처리하므로 매우 빠르며, 노이즈 생성, 블러 같은 필터 적용, 다각형에 음영 입히기처럼 컴퓨터 그래픽스 작업에 특히 잘 맞습니다.

셰이더 프로그래밍은 처음에는 낯설고 어렵게 느껴질 수 있습니다. p5.js의 2D 드로잉과는 다른 방식으로 접근해야 하기 때문입니다. 이 튜토리얼에서는 셰이더 프로그래밍의 기초를 짚어 보고, 더 살펴볼 만한 자료도 함께 소개합니다.

준비

브라우저에서 GPU를 프로그래밍하는 데 WebGL이라는 API를 사용합니다. p5.js는 셰이더 작업에 매우 유용합니다. WebGL의 반복적인 준비 과정을 대신 처리해 주기 때문에, 셰이더 코드에 집중할 수 있습니다. 셰이더를 사용해보려면 먼저 p5.js 캔버스를 WebGL 모드로 설정해야 합니다. 이를 위해 createCanvas()의 세 번째 매개변수로 WEBGL 상수를 추가합니다.

...

function setup() {
  createCanvas(200, 200, WEBGL);
}

...

셰이더 프로그램은 두 부분으로 이루어집니다. 바로 정점 셰이더(vertex shader)와 프래그먼트 셰이더(fragment shader)입니다. 정점 셰이더는 도형의 각 정점마다 한 번씩 실행되며, 그 정점이 화면 어디에 그려질지를 결정합니다. 프래그먼트 셰이더는 도형의 모든 픽셀마다 한 번씩 실행되며, 각 픽셀의 색을 결정합니다.

빨간 구

빨간 구

시간에 따라 흔들리고 뒤틀리는 빨간 구

시간에 따라 흔들리고 뒤틀리는 빨간 구

울퉁불퉁한 구의 실루엣 안이 빨강과 파랑 줄무늬로 채워진 모습

울퉁불퉁한 구의 실루엣 안이 빨강과 파랑 줄무늬로 채워진 모습

원래 형태

커스텀 정점 셰이더는 도형 안의 정점 위치를 조정할 수 있습니다

커스텀 프래그먼트 셰이더는 도형 내부의 색을 조정할 수 있습니다

이 둘은 각각 별도의 파일에 들어 있으며, loadShader() 함수로 p5.js로 불러와 setup(), draw() 에서 사용할 수 있습니다. 다음 예제를 통해 p5.js에서 기본 셰이더를 설정하는 방법을 살펴봅시다.

let myShader;
async function setup() {
  // 각 셰이더 파일을 불러옵니다
  // (걱정 마세요, 이건 곧 다시 살펴볼 거예요!)
  myShader = await loadShader('shader.vert', 'shader.frag');
  // 캔버스는 WEBGL 모드로 만들어야 합니다
  createCanvas(windowWidth, windowHeight, WEBGL);
}
function draw() {
  // shader()는 활성 셰이더를 설정하고, 
  // 이후 그려지는 것들에 적용됩니다
  shader(myShader);
  // 캔버스 전체를 덮는 사각형에 셰이더를 적용합니다
  plane(width, height);
}

추가로 createShader()라는 함수도 있는데, 이 함수는 스케치 안에 문자열로 직접 작성한 셰이더를 불러올 때 사용할 수 있습니다. 

셰이더 작성하기

이제 loadShader()에서 참조한 정점, 프래그먼트 셰이더 파일 안에 무엇이 들어가는지 살펴보겠습니다.

셰이딩 언어(GLSL)

셰이더 파일은 Graphics Library Shading Language, 즉 GLSL(OpenGL 2.0, GLSL ES 1.00 기반)로 작성합니다. 문법과 구조가 우리가 익숙한 자바스크립트와는 꽤 다릅니다. GLSL은 C와 비슷한 문법을 가지므로, 자바스크립트에는 없는 개념들도 함께 등장합니다.

우선 셰이딩 언어는 타입에 훨씬 엄격합니다. 만드는 모든 변수에는 어떤 데이터를 저장하는지 타입을 명시해야 합니다. 아래는 자주 쓰이는 타입 몇 가지입니다.

vec2(x,y)     // 두 개의 float로 이루어진 벡터
vec3(x,y,z)   // 세 개의 float로 이루어진 벡터
              // (r,g,b로도 볼 수 있음)
vec4(x,y,z,w) // 네 개의 float로 이루어진 벡터
              // (r,g,b,a로도 볼 수 있음)
float         // 소수점을 가지는 숫자
int           // 소수점이 없는 정수
sampler2D     // 텍스처를 참조하는 값
mat2          // 2x2 행렬
mat3          // 3x3 행렬
mat4          // 4x4 행렬
bool          // true 또는 false

전반적으로 셰이딩 언어는 자바스크립트보다 훨씬 엄격합니다. 세미콜론 하나만 빠져도 오류가 발생합니다. float와 int 같은 서로 다른 숫자 타입을 섞어서 쓸 수도 없습니다. 또 float 값은 반드시 소수점까지 써 주어야 하므로, 0.0이나 1.0 같은 숫자를 자주 쓰게 됩니다.

다음은 GLSL에서 달라지는 몇 가지 예시입니다.

Javascript

GLSL

모든 변수에 타입이 필요합니다.

let a = 1;
let b = 0.5;
int a = 1;
float b = 0.5;

함수는 매개변수의 타입과 반환값의 타입을 모두 선언해야 합니다.

function isBetween(val, start, end) {
  return val >= start && val <= end;
}
bool isBetween(float val, float start, float end) {
  return val >= start && val <= end;
}

int와 float 사이의 변환은 직접 해 주어야 합니다.

let a = 1;
let b = 0.5;
let c = b + 2;
let d = a + b;
int a = 1;
float b = 0.5;
float c = b + 2.0;
float d = float(a) + b;

GLSL의 반복문은 상수 값에서 멈춰야 합니다. 조건에 따라 중간에 끝내고 싶다면 break를 사용할 수 있습니다.

let maxVal = 10;
if (something) {
  maxVal = 20;
}
for (let i = 0; i < maxVal; i++) {
  // 무언가를 합니다
}
int maxVal = 10;
if (something) {
  maxVal = 20;
}
for (int i = 0; i < 20; i++) {
  if (i == maxVal) {
    break;
  }
  // 무언가를 합니다
}

제약이 많기는 하지만, 어떤 면에서는 GLSL이 더 편하게 느껴질 수도 있습니다! 특히, 벡터를 다루는 데 유용한 여러 축약 표현이 있습니다.

vec4가 있다면, 그 데이터를 색처럼 읽을 수도 있고 좌표처럼 읽을 수도 있습니다. 둘은 완전히 같으니, 코드가 더 읽기 쉬운 쪽을 골라 사용하면 됩니다.

// 각 쌍은 서로 같습니다:
myVec.x
myVec.r

myVec.y
myVec.g

myVec.z
myVec.b

myVec.w
myVec.a

모든 값이 같은 벡터를 만들고 싶을 때는 같은 값을 여러 번 반복해서 쓰지 않아도 됩니다. 한 번만 써도 충분합니다.

// 두 표현은 같습니다
myVec = vec3(0.5, 0.5, 0.5);
myVec = vec3(0.5);

더 큰 벡터에서 더 작은 벡터를 꺼낼 수도 있습니다. 이를 “스위즐링(swizzling)“이라고 하며, . 뒤에 원하는 순서대로 여러 속성을 이어 붙여 새 벡터를 만듭니다.

vec4 bigVec = vec4(1.0, 2.0, 3.0, 4.0);
// vec2(bigVec.z, bigVec.y)와 같습니다
vec2 smallVec = bigVec.zy;

정점 셰이더

다음은 간단한 정점 셰이더 예시입니다. p5.js에서 제공하는 변환과 카메라 원근을 적용합니다.

precision highp float;

셰이더는 precision 줄로 시작합니다. 여기에는 lowp, mediump, highp 중 하나를 씁니다. 처음에는 가장 높은 품질을 선택하는 것이 좋습니다. 그래야 다양한 환경에서 비슷하게 보입니다. 데스크톱이나 노트북은 어떤 값을 써도 보통 최고 품질로 처리됩니다. 휴대폰에서는 더 낮은 품질을 사용하는 것이 빠를 수 있지만, 렌더링 결과가 달라질 수 있습니다.

attribute vec3 aPosition;

셰이더의 attribute는 각 정점마다 달라지는 값을 담고 있으며, p5.js는 이를 통해 각 정점의 위치 같은 정보를 셰이더에 전달합니다. 이 셰이더에서 attribute는 vec3 타입으로, x, y, z 값을 담고 있습니다. Attribute는 정점 셰이더에서만 사용하는 특별한 변수 타입이며, 보통 p5.js가 제공합니다. rect()vertex() 같은 p5.js 함수를 사용할 때 p5.js가 정점 정보를 자동으로 셰이더에 넘겨 줍니다.

// 그려지는 객체의 변환
uniform mat4 uModelViewMatrix;
// 3D 좌표를 2D 화면 좌표로 변환합니다
uniform mat4 uProjectionMatrix;

셰이더의 uniform은 그려지는 도형 전체에서 일정한 값을 뜻합니다. 이 셰이더에서는 각 uniform이 mat4 타입인데, 이는 이동, 크기 조절, 회전 같은 변환을 표현할 때 자주 쓰이는 타입입니다. 점에 mat4를 곱하면 그 행렬이 나타내는 변환이 적용됩니다. 이 셰이더의 uniform들은 p5.js가 자동으로 제공하지만, 뒤에서 직접 커스텀 uniform을 전달하는 방법도 보게 될 것입니다. 참고로 행렬 곱셈에서는 순서가 중요합니다. 대부분의 경우 행렬을 먼저 쓰고, 그 뒤에 곱할 값을 씁니다.

void main() {
  // 카메라 변환을 적용합니다
  vec4 viewModelPosition =
    uModelViewMatrix * vec4(aPosition, 1.0);
  // 이 정점이 어디에 그려질지 WebGL에 알려 줍니다
  gl_Position =
    uProjectionMatrix * viewModelPosition;  
}

모든 정점 셰이더에는 main() 함수가 필요합니다. 이 안에서 gl_Position에 값을 할당해 정점의 위치를 정합니다. 이 값은 클립 공간(clip space)에 있으며, x, y, z 값은 한쪽 끝에서 다른 쪽 끝으로 갈 때 -1에서 1까지의 범위를 가집니다. uProjectionMatrix로 3D 점을 곱하면 p5.js의 카메라 설정을 이용해 이 변환을 자동으로 해 줍니다. 그 전에 이 셰이더는 uModelViewMatrix도 곱해 주는데, 이는 도형을 그리기 전에 설정된 누적 변환을 적용하기 위해서입니다.

아직 이 내용이 완전히 이해되지 않아도 괜찮습니다. 정점 셰이더는 중요한 역할을 하지만, 대개 프래그먼트 셰이더에서 만든 결과가 도형 위에 올바르게 보이도록 하는 것이 전부입니다. 아마 여러 프로젝트에서 같은 정점 셰이더를 반복해서 사용하게 될 것입니다. 다음은 정점별 색과 텍스처 좌표 등 정보를 처리하는 표준 정점 셰이더입니다.

precision highp float;
attribute vec3 aPosition;
attribute vec2 aTexCoord;
attribute vec4 aVertexColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying vec2 vTexCoord;
varying vec4 vVertexColor;
void main() {
  // 카메라 변환을 적용합니다
  vec4 viewModelPosition = uModelViewMatrix * vec4(aPosition, 1.0);
  // 이 정점이 어디에 그려질지 WebGL에 알려 줍니다
  gl_Position = uProjectionMatrix * viewModelPosition;  
  // 데이터를 프래그먼트 셰이더로 전달합니다
  vTexCoord = aTexCoord;
  vVertexColor = aVertexColor;
}

프래그먼트 셰이더

프래그먼트 셰이더는 셰이더의 색 출력 결과를 담당합니다. 셰이더 프로그래밍의 많은 부분이 여기서 이루어집니다. 다음은 단순히 빨간색을 표시하는 간단한 프래그먼트 셰이더입니다.

precision highp float;

프래그먼트 셰이더도 float의 precision을 지정하는 줄로 시작합니다. 이 값은 정점 셰이더의 precision과 동일해야 합니다.

void main() {
  vec4 myColor = vec4(1.0, 0.0, 0.0, 1.0);
  gl_FragColor = myColor;
}

정점 셰이더와 마찬가지로 프래그먼트 셰이더에도 main() 함수가 필요합니다. 다만 여기서는 gl_Position을 설정하는 대신, GLSL이 제공하는 특별한 변수 gl_FragColor에 색을 할당합니다.

myColor 변수는 vec4로 정의되어 네 개의 값을 저장합니다. 지금은 색을 다루고 있으므로, 빨강, 초록, 파랑, 알파입니다. 셰이더는 기본 p5.js 스케치처럼 0~255 범위의 색을 쓰지 않습니다. 대신 0.0에서 1.0 사이의 값을 사용합니다.

이제 정점 셰이더와 프래그먼트 셰이더를 모두 갖추었으니, 이 코드를 각각 별도의 파일(shader.vert, shader.frag)로 저장해 loadShader()로 스케치에 불러올 수 있습니다.

유니폼: 스케치에서 셰이더로 데이터 전달하기

이처럼 단순한 셰이더도 충분히 유용할 수 있지만, p5.js 스케치의 변수를 셰이더와 주고받아야 할 경우도 있습니다. 이럴 때 사용하는 것이 유니폼입니다. 유니폼은 스케치에서 셰이더로 보낼 수 있는 변수의 한 종류입니다. 유니폼을 사용하여 자바스크립트에서 셰이더를 훨씬 더 세밀하게 제어할 수 있습니다.

유니폼은 파일의 맨 위, main() 바깥에서 정의합니다. 이는 정점 셰이더와 프래그먼트 셰이더 양쪽 모두에서 접근할 수 있습니다. 아래 예제에서는 p5.js의 millis() 메서드가 반환하는 값을 time이라는 유니폼으로 전달해, 정점 셰이더에 움직임을 더합니다.

프래그먼트 셰이더에서도 유니폼을 똑같이 사용할 수 있습니다. 다음 예제에서는 myColor라는 색 유니폼을 만들어 자바스크립트 쪽에서 색을 바꿀 수 있게 합니다. 다만 셰이더에서는 색 채널 값이 0~255가 아니라 0~1 범위라는 점을 기억해 두세요.

p5.js가 제공하는 유니폼의 전체 목록은 p5.js WebGL Mode Architecture 문서에서 확인할 수 있습니다.

Varyings: 정점 셰이더에서 프래그먼트 셰이더로 데이터 전달하기

Varying 변수는 정점 셰이더와 프래그먼트 셰이더 사이에서 데이터를 공유합니다. 덕분에 위치나 다른 기하 정보도 프래그먼트 셰이더 안에서 활용할 수 있습니다.

예를 들어 프래그먼트 셰이더에서 도형의 텍스처 좌표를 사용하고 싶을 수 있습니다. 이 값은 vec2 형태로 들어오며, 좌표 범위는 0에서 1 사이입니다. 처음에는 p5.js가 제공하는 attribute로 들어오고, attribute는 정점 셰이더에서만 접근할 수 있습니다. 그럼 표준 정점 셰이더가 이 정보를 프래그먼트 셰이더로 어떻게 전달하는지 보겠습니다.

precision highp float;

attribute vec3 aPosition;
attribute vec2 aTexCoord;

텍스처 좌표는 처음에 aTexCoord라는 attribute 형태로 들어옵니다. 이 값은 p5.js가 자동으로 채워 줍니다.

attribute vec4 aVertexColor;

uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
varying vec2 vTexCoord;

여기서는 varying 변수를 선언합니다. 정점 셰이더에서 선언한 varying은 프래그먼트 셰이더에서도 다시 선언할 수 있습니다. 이를 통해 정점 셰이더가 할당한 값을 프래그먼트 셰이더에서 사용할 수 있습니다.

varying vec4 vVertexColor;
void main() {
  // 카메라 변환을 적용합니다
  vec4 viewModelPosition = uModelViewMatrix * vec4(aPosition, 1.0);
  // 이 정점이 어디에 그려질지 WebGL에 알려 줍니다
  gl_Position = uProjectionMatrix * viewModelPosition;
  vVertexColor = aVertexColor;

attribute의 값을 varying 변수에 할당함으로써, 프래그먼트 셰이더가 읽을 수 있는 자리로 데이터를 복사합니다.

}

정점 셰이더에서 vTexCoord라는 varying을 정의했으므로, 프래그먼트 셰이더에서도 이를 사용할 수 있습니다. 아래의 단순한 프래그먼트 셰이더는 x 값을 빨간 채널에, y 값을 초록 채널에 대응시킵니다. vTexCoord는 정점 셰이더에서는 각 정점별 값이지만, 프래그먼트 셰이더에서는 각 픽셀별 값이라는 점에 주목하세요. 픽셀별 값을 얻기 위해 WebGL은 각 면의 꼭짓점 값 사이를 부드럽게 보간합니다.

precision highp float;
varying vec2 vTexCoord;
void main() {
  // 좌표를 셰이더의 색 출력으로 할당합니다
  gl_FragColor = vec4(vTexCoord.x, vTexCoord.y, 1.0, 1.0);
}

이 셰이더를 plane(width, height)에 적용하면 다음과 같은 결과가 나옵니다.

왼쪽 위는 검정, 오른쪽 위는 마젠타, 오른쪽 아래는 흰색, 왼쪽 아래는 시안으로 보이는 직사각형 그라데이션.

필터 셰이더

p5.js에서 필터란 캔버스의 모든 픽셀을 살펴본 뒤 그것들을 다른 값으로 바꾸는 것입니다. 색을 반전하거나 캔버스에 블러를 적용하는 등 다양한 내장 필터가 있습니다. 직접 프래그먼트 셰이더를 작성해 자신만의 필터를 만들 수도 있습니다.

필터 셰이더에는 프래그먼트 셰이더만 있으면 됩니다. 정점 셰이더는 주로 도형의 위치를 정하는 역할인데, 필터는 늘 캔버스 전체에 적용되기 때문입니다. 그래서 p5.js가 필터 셰이더에 기본 정점 셰이더를 제공해 줍니다. loadShader 대신 createFilterShader(src)를 사용하고, 셰이더 소스 코드를 담은 문자열을 넣으면 됩니다.

필터 셰이더에서는 사용할 수 있는 uniform들이 몇 가지 있으며, 자세한 내용은 createFilterShader 문서에서 확인할 수 있습니다. 시작할 때 특히 알아 두면 좋은 두 가지는 다음과 같습니다.

  • uniform sampler2D tex0는 캔버스의 내용을 담고 있는 텍스처입니다.
  • varying vec2 vTexCoord는 현재 픽셀의 캔버스 좌표를 담고 있으며, 범위는 0에서 1입니다.

이 둘을 조합하여 texture2D(tex0, vTexCoord)로 현재 픽셀의 색을 반환하고, 그 색을 원하는 대로 바꿀 수 있습니다. 아래 예제에서는 파란 채널 값을 빨강과 초록 채널로 복사해, 사용자 정의 흑백 필터를 만듭니다.

또 다른 시도는 texture2D출력을 바꾸는 대신 입력을 바꾸는 것입니다. 사용하는 텍스처 좌표를 조정하면 원본에서 살짝 밀린 효과를 만들 수도 있고, 픽셀마다 오프셋을 다르게 주면 뒤틀림(warp) 효과도 만들 수 있습니다.

마무리

여기까지의 내용만으로도 기본적인 셰이더를 만들 수 있습니다. 하지만 셰이더 프로그래밍의 세계는 훨씬 더 깊고 넓으며, 이 튜토리얼에서 다루지 못한 다양한 주제와 기법이 존재합니다. p5.js의 셰이더는 시각 효과, 텍스처, 3D 기하 구조 위에 입힐 수 있는 다양한 표현을 만드는 데 강력한 도구가 됩니다.

셰이더를 더 배우고 싶다면 아래 자료도 살펴보세요!

용어집

셰이더

다양한 시각 효과와 필터를 효율적으로 만들어 낼 수 있는 GPU 프로그램입니다.

GLSL

Graphics Library Shader Language(GLSL)는 셰이더를 작성할 때 사용하는 프로그래밍 언어입니다.

유니폼 (Uniform)

스케치에서 셰이더로 전달되는 변수입니다.

Varying

정점 셰이더에서 프래그먼트 셰이더로 전달되는 변수입니다.

벡터 (vec2 / vec3 / vec4)

흔히 두 개, 세 개, 또는 네 개의 숫자를 묶어서 저장하는 데이터 타입으로, 색, 위치 등을 표현하는 데 사용됩니다.

Float

소수점을 가질 수 있는 부동소수점 숫자를 저장하는 데이터 타입입니다.

Int

소수점이 없는 정수를 저장하는 데이터 타입입니다.

Sampler

셰이더로 전달되는 텍스처를 나타내는 데이터 타입입니다. GLSL에서는 보통 sampler2D로 표현합니다.

Attribute

p5.js 스케치에서 생성되어 정점 셰이더에서 사용할 수 있게 되는 GLSL 변수입니다. 대부분의 경우 p5.js가 이를 제공합니다.

텍스처

셰이더 프로그램에 전달되는 이미지입니다. texture2D() 함수를 사용해 샘플링할 수 있습니다.

타입

int, float, vector 등 데이터의 형식을 설명하는 라벨입니다.

정점 셰이더 (Vertex Shader)

3D 공간에서 기하학적 객체의 위치를 정하는 셰이더 프로그램의 부분입니다.

프래그먼트 셰이더 (Fragment Shader)

셰이더가 출력하는 각 픽셀의 색과 외형을 담당하는 셰이더 프로그램의 부분입니다.