셰이더 드로잉: 기초(상)

좌표

셰이더로, 특히 프래그먼트 셰이더(Fragment Shader)로 그림을 그린다는 것은 화면을 구성하는 픽셀 하나의 색을 정하는 규칙으로 화면을 그리는 것이다. 검은 원을 그린다면 화면을 이루는 각 픽셀이 검은색이 될지, 혹은 검은색이 되지 않을지를 정하는 셈이다. 이는 이미 Processing이나 Canvas 2D API 등을 이용해 그림을 그리는 데 익숙해진 사람에게 강한 사고 전환을 요한다.

먼저 익숙해져야 할 것은 화면 좌표계다. p5.js 등의 그래픽 API는 대개 화면 좌측 상단을 원점으로 삼고, 형태를 그리는 함수를 원하는 좌표에 호출해 화면을 그린다. 반면 프래그먼트 셰이더에서는 보통 화면 좌측 하단을 원점으로 하는 픽셀 좌표를 받아, 이를 0부터 1 사이의 값으로 정규화해 사용한다.

fragCoord는 현재 처리하고 있는 픽셀의 위치를 의미한다. 개념적으로는 (0, 0), (1, 0), (2, 0)……과 같은 값이며, 엄밀히는 픽셀의 중심을 가리키므로 (0.5, 0.5), (1.5, 0.5), (2.5, 0.5)……와 같은 값이 된다. 프래그먼트 셰이더는 화면을 이루는 모든 픽셀에 병렬적으로 실행된다. 따라서 전체 실행의 차원에서는 사실상 모든 픽셀의 위치 값인 셈이다. 개념적으로는 2차원 좌표(x, y) 값을 입력받아 3차원 색상(r, g, b), 엄밀히는 투명도까지 4차원 값을 반환하는 함수가 픽셀의 수만큼 실행된다고 볼 수 있다. 셰이더에선 보통 fragCoord 값을 유니폼(Uniform)으로 전달받은 화면의 해상도 값으로 나눠 정규화해 사용한다. 유니폼은 CPU에서 실행되는 프로그램으로부터 GPU로 전달되는 값이다. 프래그먼트 셰이더가 픽셀마다 따로 실행되는 것과 달리 유니폼은 모든 실행에서 동일한 값을 참조한다. 그 이름처럼 모든 픽셀에 ‘한결같이(uniform)’ 적용되는 값인 셈이다.

fragCoord를 화면 해상도로 나누어 얻은 좌표는 흔히 uv라고 부른다. uv라는 이름은 텍스처 좌표의 두 축을 u, v로 부르는 관습에서 비롯되었다. 반드시 이 이름을 사용해야 하는 것은 아니기에 사람에 따라 st, coord 등으로 부르기도 한다. 이 글에서는 uv라고 칭할 예정이다. 화면에 원을 그리려면 uv가 특정 지점으로부터 얼마나 떨어져 있는지 구하면 된다. 원은 한 점에서 같은 거리만큼 떨어진 점들의 집합이다. 따라서 그 거리보다 가까운 픽셀은 검은색으로, 먼 픽셀은 흰색으로 칠하면 원을 그릴 수 있다. 이렇게 거리를 이용해 형태를 나타내는 장(場)을 SDF(Signed Distance Field)라고 하며, 그러한 장을 반환하는 함수 또한 SDF(Signed Distance Function)라 한다.

uv에 0.5를 빼면 -0.5~0.5, 이후 2를 곱하면 -1.0~1.0, 즉, 원점은 화면 중앙이다.

정규화된 uv는 기본적으로 각 축에서 0부터 1 사이의 값을 가진다. 이때 원점은 화면 좌측 하단이다. 원점을 화면 중앙으로 옮기려면 어떻게 해야 할까? uv에서 (0.5, 0.5)를 빼면 된다. 그러면 기존 원점이었던 좌측 하단은 (-0.5, -0.5), 화면 중앙은 (0.0, 0.0), 우측 상단은 (0.5, 0.5)가 된다. 이 상태에서 uv2.0을 곱하면 각 축이 대략 -1.0부터 1.0까지 이어지고, 화면 중앙을 (0.0, 0.0)으로 삼는 좌표계가 완성된다.

이제 length 함수를 이용해 원점으로부터 uv까지의 거리를 구하고, 그 값을 색상으로 출력해보자. 그러면 화면 중앙에서 바깥으로 갈수록 서서히 밝아지는 그림을 얻을 수 있다. 원점은 원점으로부터의 거리가 0.0이고, uv가 원점에서 멀어질수록 거리 값이 증가하기 때문이다. 다만 단순히 원점으로부터의 거리를 구한 값은 아직 엄밀한 의미의 원 SDF가 아니다. SDF에서는 형태의 경계를 기준으로 거리를 계산한다는 점이 중요하다. 예를 들어 원점으로부터 0.5만큼 떨어진 지점을 원의 경계로 삼는다면 다음과 같이 반지름을 빼준다. float d = length(uv) - 0.5; 이때 원점의 거리 값은 -0.5, 원의 경계—원점으로부터 정확히 0.5만큼 떨어진 위치—는 0.0이 된다. 원 밖으로 나갈수록 값은 양수로 커진다. 즉 형태의 내부는 음수, 경계는 0, 외부는 양수로 구분된다. 이처럼 거리의 부호를 통해 형태의 안과 밖을 나타내기에 ‘Signed’ Distance Field라고 부르는 것이다.

이를 step이나 smoothstep 함수를 이용해 잘라주면 원을 그릴 수 있다. step은 두 개의 인자를 받는다. 두 번째 인자가 첫 번째 인자보다 작으면 0.0, 그렇지 않으면 1.0을 반환한다. 원의 내부에서 d는 음수이므로 0.0, 외부에서는 양수이므로 1.0이 된다. 이를 그대로 색상으로 출력하면 내부가 검고 외부가 흰 원을 얻을 수 있다. smoothstep은 세 가지 인자를 받아 세 번째 인자가 첫 번째 인자보다 작다면 0으로, 두 번째 인자보다 크다면 1로, 첫 번째, 두 번째 인자 사이라면 부드럽게 보간하여 반환하는 함수다. 이를 이용해 원을 그리면 다음과 같다.

타원이 그려졌다. 이는 가로·세로 모두 0부터 1 사이의 값인 uv 좌표계와 화면의 실제 해상도가 다르기 때문이다. 예를 들어 가로로 긴 화면의 우측 상단은 uv 좌표계에서 (1.0, 1.0)이지만, 실제 픽셀 좌표로는 (1920, 1080)처럼 가로가 더 긴 값을 가질 수 있다. 따라서 uv에서 같은 0.5라는 길이라도 x축과 y축에서 서로 다른 수의 픽셀을 차지한다. 정규화된 좌표계에서는 같아 보이는 길이가 실제 화면에서는 다르게 나타나는 것이다. 이를 해결하려면 화면비를 보정해야 한다. x축과 y축의 한 단위가 실제 화면에서도 같은 길이를 나타내도록 좌표계를 조정하는 것이다.

화면비 보정 방법은 단순하다. 화면의 실제 해상도가 가지는 세로에 대한 가로의 비율(가로/세로), 즉 종횡비를 0에서 1 사이인 uv좌표의 x 성분에 곱해 실제 해상도에 비례하게 만드는 것이다. 혹은 종횡비의 역수, 즉 가로에 대한 세로의 비율(세로/가로)을 y 성분에 곱해줘도 되며, y성분을 종횡비로 나누는 등, uv 좌표계를 실제 해상도에 비례하게만 만들면 그만이다. 혹은 그저 화면 중심을 기준으로 한 픽셀 좌표를 화면의 짧은 변으로 나눌 수도 있다. min 함수를 이용하는 것인데, 결국 어느 축에 종횡비를 곱하거나 나눌지, 혹은 화면의 짧은 변을 기준으로 전체 좌표를 정규화할지는 선택의 문제다. 중요한 것은 셰이더가 계산하는 거리와 화면에 나타나는 거리가 서로 일치하도록 좌표계를 구성하는 데 있다.

min 함수를 이용해 화면의 더 짧은 폭에 대한 긴 폭의 비율을 uv에 곱하였다.

형태들

원을 그릴 수 있다면 다른 도형도 그릴 수 있을 것이다. Inigo Quilez의 웹사이트에 2D SDF 함수를 정리한 글이 있다. 이를 이용해 각 도형을 어떻게 그릴 수 있을지 알아보자.

다른 도형을 그리기 전 형태를 합치거나 빼는 기법을 알아야 한다. 위에 잠시 언급된 min 함수와 max 함수를 이용하는 것이다. min 함수는 두 인자를 받아 둘 중 작은 값을 반환하며, max 함수는 그 반대다. SDF는 형태의 내부에서 음수, 경계에서 0, 외부에서 양수를 반환한다. 따라서 두 SDF 값을 min 함수에 넣으면, 어느 한 형태의 내부에만 있어도 음수가 선택된다. 두 형태의 외부에서는 더 가까운 형태까지의 거리가 선택된다. 결과적으로 min(dA, dB)는 두 형태를 합친 합집합을 나타낸다.

max 함수는 두 값 중 큰 값만을 반환하기에 두 거리 값 중 하나라도 양수라면—형태 외부라면—양수 값을 반환한다. 그렇기에 max(dA, dB)는 두 형태가 겹치는 부분, 즉 교집합을 나타낸다.

만일 max 함수를 이용하며 거리 값 중 하나는 그대로, 다른 하나는 반전 값—형태 내부가 양수, 외부가 음수인 값—을 넣으면 어떻게 될까? 두 값 중 큰 값을 반환하기에 한 형태에서 다른 형태—값을 반전하여 넣은 형태—를 뺀 값을 반환한다. 즉, max(dA, -dB)는 차집합을 나타낸다.

형태의 외곽선을 그릴 수도 있다. abs 함수를 이용하는 것이다. abs 함수는 절댓값을 반환한다. 여기 SDF 값—실수 거리 값—을 집어넣으면 형태의 경계에서 0을, 내·외부로 점차 커지는 양수 값을 반환하게 된다. 그 값에 임의의 수를 빼 주면 경계를 중심으로 일정 두께의 띠를 얻을 수 있다. 예를 들어 0.1을 빼 보자. 기존 형태의 경계는 -0.1, 내·외부로 0.1만큼 떨어진 곳이 0.0, 이를 기점으로 점차 커지는 양수 값을 반환한다. abs(dst) - outlineWeight, 그렇게 외곽선을 그릴 수 있다.

이제 원 외의 다른 형태를 어떻게 그릴 수 있을지 살펴보자. 살펴볼 모든 식은 원점이 화면 중앙이라는 가정하에 작성된다.

사각형

float sdBox( in vec2 p, in vec2 b )
{
    vec2 d = abs(p)-b;
    return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
}

abs(uv.x) - 0.1은 원점으로부터 x축으로 각 0.1씩 떨어진 형태를 반환하며, abs(uv.y) - 0.1는 y축으로 각 0.1씩 떨어진 형태를 반환한다. max 함수로 이 두 형태의 교집합을 구하면 사각형을 그릴 수 있다.

단, 위 함수에는 max(d, 0.0)의 거리를 구하는 식이 포함되어 있음을 알 수 있다. 이는 사각형의 꼭짓점 때문이다. 0.5의 크기를 가진 사각형이 있다고 할 때, (0.7, 0.7) 위치는 가장 가까운 사각형의 꼭짓점—사각형의 오른쪽 위 꼭짓점, (0.5, 0.5)—와 거리를 구해야 한다. 즉, vec2(0.7)-vec2(0.5)—사각형 꼭짓점에서 임의의 점 (0.7, 0.7)까지의 거리—, sqrt(0.2² + 0.2²) ≈ 0.283인데, 교집합을 구하는 방식만으로는 0.2라는 실제 거리와 다른 값이 구해진다. 그렇기에 length 함수를 이용해 max(d, 0.0)—도형 밖만 남긴 값—의 실제 거리를 구해 적용하는 것이다. 이를 min(max(d.x, d.y), 0.0)—도형 안쪽만 남긴 값—과 합쳐 사각형을 거리로 나타낸다.

여러 종류의 선 중 끝(Stroke Cap)이 둥근 선과 각진 선을 각각 살펴볼 예정이다. 끝이 둥근 선부터.

float sdSegment( in vec2 p, in vec2 a, in vec2 b )
{
    vec2 pa = p-a, ba = b-a;
    float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 );
    return length( pa - ba*h );
}

우선 Inigo Quilez의 함수는 인자로 벡터 a와 b를 받는다. a에서 임의의 점 p를 향하는 벡터, a에서 b를 향하는 벡터를 구한다. 여기서 벡터의 내적이 나오는데, 나는 이 부분을 계속 까먹고 헷갈리니 여기서 정리를 해 보자.

$$\vec{a} \cdot \vec{b} = \lVert \vec{a} \rVert \lVert \vec{b} \rVert \cos(\theta)$$

벡터 내적은 각 벡터의 길이와 두 벡터의 방향 관계로 다시 나타낼 수 있다.

$$\cos(\theta) = \cfrac{인접변}{빗변}$$

두 벡터를 원점으로 평행이동 시킨 후, 벡터 a의 끝에서 벡터 b를 향해 수선의 발을 내리면 벡터 a를 빗변으로, 벡터 b가 놓인 직선 위에 내려진 수선의 발까지를 인접변(밑변)으로 삼는 직각 삼각형을 상상할 수 있다. 이때 임의의 벡터 끝에서 다른 벡터로 수선의 발을 내리는 것을 투영(Projection)이라 칭한다. 코사인 함수의 삼각비에 따라 인접변, 즉 투영 지점까지의 길이를 구할 수 있는데, 이렇게 크기를 구하는 투영을 스칼라 투영(Scalar Projection), 해당 크기에 벡터 b의 방향 성분 벡터를 곱하여 투영 벡터를 구하는 것을 벡터 투영(Vector Projection)이라 부른다.

$$인접변 = \cos(\theta) 빗변$$

$$\vec{a} \cdot \vec{b} = \lVert \vec{a} \rVert \cos(\theta) \cdot \lVert \vec{b} \rVert$$

여기서 중요한 지점을 확인할 수 있다. 벡터의 내적은 코사인 각도와 각 벡터 길이의 곱으로 나타낼 수 있다는 것이다. 여기서 투영 기준으로 사용할 벡터를 방향 성분만 남기고 단위화하여 내적한다면 해당 벡터의 길이는 1이기에 내적만으로 스칼라 투영, 즉 수선의 발까지의 길이를 구할 수 있게 된다. 두 벡터 중 어느 것도 단위화하지 않는다면 식에 따라 결과값에서 인접변으로 사용할 벡터의 크기로 나누어 스칼라 투영 값을 구할 수 있다.

두 벡터 모두 단위화하여 내적하면 위 식에 따라 -1부터 1 사이의 코사인 값만 남는다. 코사인 함수는 동일한 각도에서 빗변에 대한 인접변의 비율을 반환한다. 여기서 빗변은 첫 번째 벡터, 인접변은 두 번째 벡터 방향으로 수선의 발까지의 크기를 가지는 벡터를 의미한다. 코사인 값이 0이라면, 코사인 각도는 90°라는 것이고, 인접변의 길이는 0이 된다. 코사인 값 0은 첫 번째 벡터와 두 번째 벡터의 방향이 수직하여 일치하지 않는다는 의미다. 코사인 값이 1이라면 빗변이 인접변에 완전히 평행하여 인접변과 빗변의 크기가 동일함을 의미하게 된다. 값이 -1이라면 첫 번째 벡터와 두 번째 벡터는 반대 방향으로 평행할 것이다. 이렇게 코사인 값으로 두 벡터의 방향이 일치하는 정도를 구할 수 있다.

각설하고 다시 선을 그리는 코드를 살펴보자. 임의의 벡터 ap와 ab의 내적을 확인할 수 있다. 그 값을 ab끼리의 내적으로 나누는데, 같은 벡터끼리의 내적은 해당 벡터의 길이 제곱과 같다. ab의 길이는 sqrt(ab.x² + ab.y²)이고, 해당 벡터끼리의 내적은 (ab.x² + ab.y²)이기 때문이다. 그렇기에 식은 아래와 같은 셈이다.

$$\cfrac{\vec{ap} \cdot \vec{ab}}{\vec{ab} \cdot \vec{ab}} = \cfrac{\lVert \vec{ap} \rVert \lVert \vec{ab} \rVert \cos(\theta)}{\lVert \vec{ab} \rVert^2}$$

$$\cfrac{\vec{ap} \cdot \vec{ab}}{\vec{ab} \cdot \vec{ab}} = \cfrac{\lVert \vec{ap} \rVert \cos(\theta)}{\lVert \vec{ab} \rVert}$$

위에서 두 벡터 모두 단위화하지 않고 내적하면 인접변으로 사용할 벡터의 크기로 나누어 스칼라 투영 값을 구할 수 있다고 했었는데, ap·ab / |ab|까지 계산하면 apab 방향 스칼라 투영 길이가 된다. 그런데 여기서는 다시 |ab|로 한 번 더 나눈다. 따라서 결과는 실제 길이가 아니라, 투영 길이가 선분 ab 전체 길이의 몇 배인지를 나타내는 비율이 된다. 길이가 유한한 선분을 그리기 위해 clamp 함수를 이용해 0부터 1 사이 값으로 눌러 주고 h를 이용해 ab의 어떤 구체적 지점에서 ap를 향하는 거리 값을 반환하면 끝이 둥근 선분을 그릴 수 있게 된다.

벡터 투영은 아래와 같이 표현될 수 있다. 벡터 투영을 이용해 그린 선분으로 벡터 투영을 표현하는 셈이다.

끝이 각진 선분을 그리는 Inigo Quilez의 함수는 mat2 함수를 이용한다. 이는 이후 알아보기로 하고, 함수를 풀어 살펴볼 것이다.

float sdOrientedBox( in vec2 p, in vec2 a, in vec2 b, float th )
{
    float l = length(b-a);
    vec2  d = (b-a)/l;
    vec2  q = (p-(a+b)*0.5);
          q = mat2(d.x,-d.y,d.y,d.x)*q;
          q = abs(q)-vec2(l,th)*0.5;
    return length(max(q,0.0)) + min(max(q.x,q.y),0.0);    
}

아래는 풀어 쓴 함수다.

float sdOrientedBox(vec2 p, vec2 a, vec2 b, float w){
  vec2 pa = p - a;
  vec2 ba = b - a;
  
  float len = length(ba);
  vec2 dir = normalize(ba);
  
  float dt = dot(pa, ba) / len;
  vec2 lp = a + dir * dt;
  
  float v = max(-dt, dt - len);
  float h = length(p - lp) - w;
  
  float outer = length(max(vec2(v, h), 0.0));
  float inner = min(max(v, h), 0.0);
  float dst = outer + inner;
  
  return dst;
}

우선 끝이 둥근 선분과 마찬가지로 a부터 b를 향하는 벡터 ba, a부터 임의의 점 p를 향하는 벡터 pa를 이용해 내적한다. dtpaba 방향으로 투영했을 때의 크기가 된다. lp는 이를 a로부터 반영한 투영점이고, h는 임의의 점 plp 사이의 거리로 ba를 포함하는 직선에 대한 거리값이다. ba를 포함하는 직선과 임의의 점 p 사이의 수직 거리를 구했다면, 다음으로는 ba의 방향을 따라 투영점이 선분의 범위 안에 있는지를 구해야 한다. dt는 0 이상이어야 하며 length(ba) 이하여야 한다. 즉 dt >= 0dt <= length(ba)를 동시에 만족해야 한다.

회전 변환

앞서 Inigo Quilez는 끝이 각진 선분을 그리는 데 mat2 함수를 이용한다고 하였다. 이를 이해하기 위해 다시 좌표에 대해 이야기할 필요가 있다.

보통 좌표를 (x, y)로 표현한다. 여기서 x와 y는 각각 x, y 축으로 얼마나 갔는지를 의미한다. 따라서 기본 좌표축을 벡터로 쓰면 x축 방향 = (1, 0) y축 방향 = (0, 1)이고, (x, y)라는 것은 사실 아래와 같다.

$$(x, y) = x \cdot (1, 0) + y \cdot (0, 1)$$

즉 모든 2차원 벡터는 두 방향을 얼마씩 섞었는가로 표현된다.

그런데 꼭 화면의 x축, y축만을 기준으로 사용해야 할까? 그렇지 않다. 임의의 선분 ab를 기준 축으로 삼을 수 있다.

vec2 d = (b-a) / length(b-a);

위는 새 기준으로 삼을 ab의 방향 단위 벡터다. 이를 새로운 x축으로 사용한다면 그에 따른 y축은 d에 수직인 방향이면 된다. d가 (d.x, d.y)라면, 한쪽으로 90° 돌린 벡터 하나는 (-d.y, d.x)이다. 반대쪽으로 90° 돌린다면 (d.y, -d.x)이다

그렇게 d를 x축으로, d를 기준으로 90° 돌린 새 벡터를 y축으로 삼는 새로운 좌표계가 생긴 셈이다.

여기서 행렬이 등장한다. 2차원 선형변환은 기본 x축 (1,0)과 y축 (0,1)이 각각 어디로 변환되는지를 알면 결정할 수 있다. 두 변환 결과를 열벡터로 나란히 놓으면 2×2 행렬로 표현할 수 있다.

$$\begin{bmatrix} a & b \cr c & d \end{bmatrix}$$

행렬의 성분은 행, 열끼리 벡터로 묶일 수 있는데, 이를 각각 행벡터, 열벡터라고 한다. 이를 이용해 위에서 한 이야기를 다시 표현할 수 있다.

$$\mathbf{p}^{\prime} = x \cdot \begin{bmatrix} 1 \cr 0 \end{bmatrix} + y \cdot \begin{bmatrix} 0 \cr 1 \end{bmatrix}$$

그럼 회전된 좌표계는 아래와 같을 것이다.

$$\mathbf{p}^{\prime} = x \cdot \begin{bmatrix} 0 \cr 1 \end{bmatrix} + y \cdot \begin{bmatrix} -1 \cr 0 \end{bmatrix}$$

어떤 좌표계 전체를 θ만큼 돌린다면 x축은 (cosθ, sinθ), y축은 해당 x축을 기준으로 90° 회전한 (-y, x)이기에 (-sinθ, cosθ)이며, 각각의 열벡터를 행렬로 합치면 아래와 같다.

$$(1, 0) \xrightarrow{\theta} (\cos\theta, \sin\theta) \xrightarrow{90^\circ} (-\sin\theta, \cos\theta)$$

$$\mathbf{e}^{\prime}_x = \begin{bmatrix} \cos\theta \cr \sin\theta \end{bmatrix}, \quad \mathbf{e}^{\prime}_y = \begin{bmatrix} -sin\theta \cr \cos\theta \end{bmatrix}$$

$$R(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta \cr \sin\theta & \cos\theta \end{bmatrix}$$

행렬끼리 곱할 수 있다. 첫 행렬의 행과 두 번째 행렬의 열끼리 곱하고 더하면 된다. 그럼 회전변환 식을 행렬로 묶고 임의의 열벡터와 행렬곱을 한다면 보다 깔끔하게 계산할 수 있게 된다.

임의의 점 p를 회전시킨다면?

$$\mathbf{p} = \begin{bmatrix} x \cr y \end{bmatrix}$$

$$R(\theta)\mathbf{p} = \begin{bmatrix} \cos\theta & -\sin\theta \cr \sin\theta & \cos\theta \end{bmatrix} \times \begin{bmatrix} p_x \cr p_y \end{bmatrix}$$

$$R(\theta)\mathbf{p} = \begin{bmatrix} p_x \cos\theta -p_y \sin\theta \cr p_x \sin\theta + p_y \cos\theta \end{bmatrix}$$

$$R(\theta)\mathbf{p} = p_x \begin{bmatrix} \cos\theta \cr \sin\theta \end{bmatrix} + p_y \begin{bmatrix} -sin\theta \cr \cos\theta \end{bmatrix}$$

풀어보면 앞서 살펴본 식과 형태가 같다. 행렬을 이용한 회전 변환이다. 그럼 mat2 함수는 무엇인가? 이는 2×2 부동소수점 행렬 타입인 동시에 해당 행렬의 생성자 함수다. 여기서 중요한 점은 GLSL에서 mat2 함수는 열을 기준으로 값을 채운다는 것이다. mat2(a, b, c, d)는 아래와 같은 행렬을 반환한다.

$$\begin{bmatrix} a & c \cr b & d \end{bmatrix}$$

따라서 GLSL에서는 회전 변환 행렬을 다음과 같이 열벡터 기준으로 적는다.

mat2(
    cos(theta),  sin(theta),
   -sin(theta),  cos(theta)
)

이를 이용해 도형을, 구체적으로는 도형을 그려내는 좌표계를 회전시킬 수 있다.

이제 Inigo Quilez의 끝이 각진 선분을 그리는 함수를 다시 살펴보자.

float sdOrientedBox( in vec2 p, in vec2 a, in vec2 b, float th )
{
    float l = length(b-a);
    vec2  d = (b-a)/l;
    vec2  q = (p-(a+b)*0.5);
          q = mat2(d.x,-d.y,d.y,d.x)*q;
          q = abs(q)-vec2(l,th)*0.5;
    return length(max(q,0.0)) + min(max(q.x,q.y),0.0);    
}

l은 ab의 길이, d는 ab의 방향 단위 벡터이다. q가 중요해 보이는데, ab의 중점에서 임의의 점 p를 향하는 벡터다. q에 행렬을 곱하는데, 이 행렬을 수식으로 나타내면 아래와 같다.

$$\begin{bmatrix} d_x & d_y \cr -d_y & d_x \end{bmatrix}$$

여기 d.x​=cosθ, d.y​=sinθ를 대입하면 아래와 같다.

$$\begin{bmatrix} \cos\theta & \sin\theta \cr -\sin\theta & \cos\theta \end{bmatrix}$$

이는 조금 전 살펴본 회전 변환 행렬의 형태와 조금 다르다. 점 q만큼 회전시켜, 선분 ab가 x축과 평행한 로컬 좌표계로 옮긴 것이다. q를 ab의 방향 벡터를 기준 x축으로 삼도록 역으로 회전시킨 셈이다. d가 ab의 방향 성분 벡터이기에, 좌표계를 해당 방향의 각도만큼을 역으로 돌리면 수평이 되므로, 마치 기준 축에 걸쳐있는 사각형처럼 코드를 작성할 수 있게 된다.

좌표계를 선의 방향(이 이루는 각도)만큼 역 회전

의사 난수

컴퓨터로 무작위한 수를 생성하는 일은 매우 힘들다고 어디선가 들은 적 있다. 아마 출력은 입력을 필요로 하는데, 입력은 의도를 내포하기 때문이 아닐까 싶다. 클라우드플레어(Cloudflare)에서는 이런 한계를 극복하기 위해 라바 램프의 움직임을 기반으로 난수를 생성한다고 한다. 실제로 무작위한 난수를 출력하기 위해서는 외부 세계의 힘을 빌릴 필요가 있다. 그럴 때는 하드웨어나 운영체제가 수집한 현실 세계의 예측 불가능성을 시드로 활용하기도 한다. 백색 소음이나 매미 소리, 혹은 물의 일렁임 등을 입력으로 사용하는 것이다. 이는 다소 부담스러운 일이지만, 다행히도 현실의 무작위성을 흉내내는 방법 또한 존재한다. 그런 흉내쟁이 난수 값을 의사 난수(Pseudo Random)라 부른다. 이런 함수는 입력에 따하 결정론적이기에 동일한 입력 시 출력 또한 동일하다. 아이러니하게도 예측 가능한 혼란인 것이다.

GLSL(OpenGL Shading Language)에서는 보통 sin같은 주기 함수를 이용해 랜덤 함수를 만든다. sin은 입력받는 값에 따라 -1에서 1 사이의 값을 출력하는 주기 함수다. 여기에 임의의 큰 수를 곱해보자. 예를 들어 0.83244...의 출력은 34553.63903...의 출력이 된다. 0.841470, 0.841623과 같이 주기를 가진 값에 큰 수를 곱하면 값의 사이가 벌어진다. 그 값을 fract 함수에 넣으면 정수 부분을 버리고 소수점 이하만 남긴다. 즉, sin함수가 만들어낸 큰 값의 소수 자릿수를 잘라 확대해 보는 것이다.

float random(float x) {
    return fract(sin(x)*42354.34264);
}

위는 한 가지 값을 입력받아 한 가지 난수를 출력하는 코드다. 두 가지 값을 입력받게 하려면 같은 차원의 값으로 내적하면 된다. 결국 sin 함수의 값에 임의의 큰 수를 곱하고 fract하는 것이 중요하다. 입력과 출력의 차원은 직접 정할 수 있다.

float random2(vec2 p) {
    return fract(sin(dot(p, vec2(23.41433, 67.94152)))*81234.53376);
}
float random3(vec3 p){
    return fract(sin(dot(p, vec3(23.3234,10.6345,12.3412)))*63495.34598);
}

3차원 벡터를 입력받는 랜덤 함수에 x, y 값을 화면 2차원 좌표로 입력하면 남은 성분 인자 하나를 일종의 시드(Seed)처럼 사용할 수 있게 된다. 이를 이용해 혼란스러운 화면을 그릴 수 있다.

random 함수는 결국 입력 값을 임의의 방법으로 뒤섞어 입력과 무관해 보이는 출력을 하도록 제작되었다. 이처럼 입력을 뒤섞어 출력하는 식의 함수를 엄밀하게는 해시 함수(Hash Function)라 칭한다. 앞서 사용한 sin 기반 해시 함수는 빠르고 간결하지만 입력 값에 따라 관계나 패턴이 보일 수 있다. 더 무작위한 결과를 위해서는 더 나은 해시 함수가 필요하다. 필요하다면 David Hoskins의 해시 함수를 사용하면 될 것이다. 아래는 필요할 때 사용할 수 있도록 그대로 복사해온 코드다.

// Hash without Sine
// MIT License...
/* Copyright (c)2014 David Hoskins.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.*/

//----------------------------------------------------------------------------------------

//The name "hash[Out][In]" describes the function signature:
// * EXAMPLES:
// * hash12: float = hash(vec2)  // 1 component out, 2 components in
// * hash21: vec2  = hash(float)  // 2 components out, 1 component in
// * hash22: vec2  = hash(vec2)  // 2 components out, 2 components in
// * hash33: vec3  = hash(vec3)  // 3 components out, 3 components in
float hash11(float p)
{
    p = fract(p * .1031);
    p *= p + 33.33;
    p *= p + p;
    return fract(p);
}

//----------------------------------------------------------------------------------------
//  1 out, 2 in...
float hash12(vec2 p)
{
	vec3 p3  = fract(vec3(p.xyx) * .1031);
    p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.x + p3.y) * p3.z);
}

//----------------------------------------------------------------------------------------
//  1 out, 3 in...
float hash13(vec3 p3)
{
	p3  = fract(p3 * .1031);
    p3 += dot(p3, p3.zyx + 33.33);
    return fract((p3.x + p3.y) * p3.z);
}
//----------------------------------------------------------------------------------------
// 1 out 4 in...
float hash14(vec4 p4)
{
	p4 = fract(p4  * vec4(.1031, .1030, .0973, .1099));
    p4 += dot(p4, p4.wzxy+33.33);
    return fract((p4.x + p4.y) * (p4.z + p4.w));
}

//----------------------------------------------------------------------------------------
//  2 out, 1 in...
vec2 hash21(float p)
{
	vec3 p3 = fract(vec3(p) * vec3(.1031, .1030, .0973));
	p3 += dot(p3, p3.yzx + 33.33);
    return fract((p3.xx+p3.yz)*p3.zy);

}

//----------------------------------------------------------------------------------------
///  2 out, 2 in...
vec2 hash22(vec2 p)
{
	vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973));
    p3 += dot(p3, p3.yzx+33.33);
    return fract((p3.xx+p3.yz)*p3.zy);

}

//----------------------------------------------------------------------------------------
///  2 out, 3 in...
vec2 hash23(vec3 p3)
{
	p3 = fract(p3 * vec3(.1031, .1030, .0973));
    p3 += dot(p3, p3.yzx+33.33);
    return fract((p3.xx+p3.yz)*p3.zy);
}

//----------------------------------------------------------------------------------------
//  3 out, 1 in...
vec3 hash31(float p)
{
   vec3 p3 = fract(vec3(p) * vec3(.1031, .1030, .0973));
   p3 += dot(p3, p3.yzx+33.33);
   return fract((p3.xxy+p3.yzz)*p3.zyx); 
}


//----------------------------------------------------------------------------------------
///  3 out, 2 in...
vec3 hash32(vec2 p)
{
	vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973));
    p3 += dot(p3, p3.yxz+33.33);
    return fract((p3.xxy+p3.yzz)*p3.zyx);
}

//----------------------------------------------------------------------------------------
///  3 out, 3 in...
vec3 hash33(vec3 p3)
{
	p3 = fract(p3 * vec3(.1031, .1030, .0973));
    p3 += dot(p3, p3.yxz+33.33);
    return fract((p3.xxy + p3.yxx)*p3.zyx);

}

//----------------------------------------------------------------------------------------
// 4 out, 1 in...
vec4 hash41(float p)
{
	vec4 p4 = fract(vec4(p) * vec4(.1031, .1030, .0973, .1099));
    p4 += dot(p4, p4.wzxy+33.33);
    return fract((p4.xxyz+p4.yzzw)*p4.zywx);
    
}

//----------------------------------------------------------------------------------------
// 4 out, 2 in...
vec4 hash42(vec2 p)
{
	vec4 p4 = fract(vec4(p.xyxy) * vec4(.1031, .1030, .0973, .1099));
    p4 += dot(p4, p4.wzxy+33.33);
    return fract((p4.xxyz+p4.yzzw)*p4.zywx);

}

//----------------------------------------------------------------------------------------
// 4 out, 3 in...
vec4 hash43(vec3 p)
{
	vec4 p4 = fract(vec4(p.xyzx)  * vec4(.1031, .1030, .0973, .1099));
    p4 += dot(p4, p4.wzxy+33.33);
    return fract((p4.xxyz+p4.yzzw)*p4.zywx);
}

//----------------------------------------------------------------------------------------
// 4 out, 4 in...
vec4 hash44(vec4 p4)
{
	p4 = fract(p4  * vec4(.1031, .1030, .0973, .1099));
    p4 += dot(p4, p4.wzxy+33.33);
    return fract((p4.xxyz+p4.yzzw)*p4.zywx);
}

모든 코드는 웹사이트 임베딩을 위해 『The Book of Shaders』의 저자 Patricio Gonzalez Vivo가 제작한 오픈소스 웹 어플리케이션 「glslEditor」로 작성되었다.

다음 글로 이어집니다.