이전 글에서 이어집니다.
격자
HTML Canvas 2D 등의 그래픽 API에서는 주로 반복문을 이용해 격자를 그린다.
p.setup = () => {
p.createCanvas(window.innerWidth, window.innerHeight);
const span = 40;
for (let j = 0; j < p.height / span; j++) {
for (let i = 0; i < p.width / span; i++) {
p.rect(i * span, j * span, span, span);
}
}
};이는 매우 직관적이지만, 도형을 반복해 그릴수록 성능 문제를 일으킬 수 있다. 그러나 프래그먼트 셰이더는 그러한 한계에서 벗어날 수 있게 해 준다. 결국 모든 픽셀에서 한 번씩 실행되기에 도형을 얼마나 반복하든 연산 횟수 자체는 동일하기 때문이다. 이를 이용해 패턴을 그리는 방법을 알아보자.
이전 글에서 난수를 만들 때 fract 함수를 이용했다. fract는 입력값 x에서 floor(x)를 뺀 값을 반환한다. 이를테면 fract(2.3534)의 반환 값은 0.3534다. 그렇다면 fract의 인자로 uv를 넣으면 어떻게 되는가. uv는 픽셀의 위치를 화면 해상도로 나눠 정규화한 값이다. 기본적으로 0부터 1 사이 값이기에 fract(uv)는 무의미하다. 하지만 uv를 스케일링한다면 의미가 생겨난다. 예를 들어 fract(uv*3.0)은 0부터 1 사이로 3번 반복되는 uv좌표를 반환하는 것이다. 해당 좌표계를 이용하면 연산 횟수를 고정하며 형태를 반복할 수 있게 된다.
이를 이용해 격자를 그리는 코드다. 한 가지 문제가 있다면 선의 굵기는 격자 내에서 고정된 값이라 uv의 스케일에 따라 얇아 보이기도, 굵어 보이기도 한다는 것이다. 위 코드에서는 0.01의 값인데, uv가 커질 때 격자 내 공간 속 0.01의 값은 같은 비율로 커지지 않기에 발생하는 현상이다. 이를 해결하는 방법은 여러 가지가 있다.
당연하게도 가장 간단한 방법은 uv의 스케일 비율만큼 선의 굵기를 키우는 것이다. 그렇게 하면 개념적으로는 동일한 굵기로 그려질 것이다. 그러나 위 결과에서 드러나듯 항상 일정한 굵기로 보이지는 않는다. 이는 앨리어싱(Aliasing)의 문제다. 결국 모든 형태는 픽셀이라는 격자로 표현되어야 하기에, 선이 그려지는 위치에 따라 1픽셀씩 더 혹은 덜 포함되어 그려질 수 있기 때문이다.
이를 해결하기 위해서는 보다 난해한 방법이 필요하다. 화면의 기울기를 구하는 것이다. 여기서 기울기라는 건 x가 1 이동했을 때의 y값의 변화량이다. GLSL에는 dFdx와 dFdy라는 함수가 존재한다. dFdx(v)는 화면 좌표 x값이 커지는 방향으로, 즉 왼쪽에서 오른쪽으로 한 칸 이동될 때의 v의 변화량(기울기)을 반환한다. x좌표가 반전되어있어도 화면 왼쪽 끝에서 오른쪽 끝 방향이며, 반환되는 기울기 값의 부호가 반전될 뿐이다. dFdy(v)는 화면 좌표 y값이 커지는 방향, 즉 아래에서 위로 약 한 픽셀 이동할 때의 v의 변화량을 반환한다.
fwidth라는 함수도 존재한다. fwidth(v)는 화면의 x방향과 y방향으로 한 픽셀 이동했을 때의 v의 변화 크기를 반환한다. 즉 abs(dFdx(v)) + abs(dFdy(v))와 동일한 셈이다. 이를 smoothstep의 인자로 사용하면 값의 변화가 급격할수록, 즉 형태의 경계가 분명한 만큼 현재 값의 변화율에 맞춰 보간 폭을 자동으로 정해 픽셀 단위로 칼같이 색이 잘리는 현상을 방지한다. uv가 커진 만큼 굵기를 키운다면, fwidth로 형태 경계를 보간해 주는 것이다. 이를 안티앨리어싱(Antialiasing)이라고 부른다.
fwidth함수는 흥미롭다. 해당 함수에 화면 좌표계를 입력하면 화면에서 약 1픽셀 이동했을 때 좌표계가 변하는 정도를 구할 수 있는데, 이는 사실상 입력된 좌표계에서 한 픽셀의 폭으로 볼 수 있다. uv를 스케일해도 좌표계의 한 픽셀 비율과 다름없기에 이는 유효하다. 이를 이용하면 좌표계에 따라 정규화된 거리값이 실제로 몇 픽셀 정도를 차지하는지 근사할 수 있다. 예를 들어 축별 거리값이라면 d / fwidth(grid)처럼 나누어 픽셀 단위 거리처럼 바꿀 수 있고, 스칼라 거리장이라면 dst / fwidth(dst)처럼 사용할 수 있는 것이다. 이를 통해 선의 굵기를 픽셀 단위로 고정하며 굵기가 안정적인 격자를 그릴 수도 있다.
#extension GL_OES_standard_derivatives : enable참고로 dFdx, dFdy, fwidth 등의 함수는 WebGL 1.0 환경에서 기본적으로 내장되어있지 않다. 이를 사용하기 위해서는 OES_standard_derivatives 확장을 켜 줘야 한다. WebGL 2.0 이상의 환경에서는 기본적으로 지원한다.
fract 함수를 사용하지 않아도 격자를 그릴 수 있다. mod 함수는 두 가지 인자를 받아 첫 번째 인자를 두 번째 인자로 나눈 나머지값을 반환한다. 예를 들어 mod(3.345, 2.0)은 1.345를 반환한다. fract 함수는 사실상 mod(v, 1.0)인 셈이다.
vec2 cell = vec2(20.0);
vec2 iuv = floor(uv * cell); //id
vec2 fuv = fract(uv * cell); //격자 공간
vec3 color = vec3(fuv, 0.5);
color *= mod(iuv.x - iuv.y, 2.0); //체커패턴격자를 그린다고 하지만, 결국 핵심은 그림이 그려지는 공간을 반복하는 것이다. 반복되는 격자 공간의 각 셀(Cell)에 차이를 주기 위해선 현재 픽셀이 어느 셀 공간에 속해 있는지 알아야 한다. 이때 floor 함수를 사용한다. floor(v)는 v 이하의 가장 큰 정수를 반환한다. 격자를 그리기 위해 공간을 4배 키웠다고 하자. floor 함수를 이용해 정수 부분만 남긴다면 (2.535, 3.184)의 값을 가진 픽셀은 (2.0, 3.0)을 반환한다. 일종의 셀 ID를 반환하는 것이다. 그렇다면 공간은 사실상 소수점으로 쪼개진 부분공간과 정수로 이루어진 셀 위치의 합, 즉 fract(uv) + floor(uv)가 되는 셈이다. 이를 이용해 각 공간에 다른 그림을 그릴 수 있다.
앞서 살펴본 난수 생성 함수의 인자로 셀의 위치 정보를 입력하면 각 셀 위치에 해당하는 난수를 반환한다. 각 셀마다 다른 난수를 가지게 될 테고, 이를 그림을 그리는 데 사용한다면 부분공간별로 다른 그림을 그릴 수 있게 된다. 어느 셀인지, 그 셀 안에서 어느 위치인지 나눠 그림을 그리는 것이다.
반복문으로 화면을 400×400 격자로 분할하고 각 공간에 그림을 그리려면 160000번 이상의 연산이 필요하다. CPU는 순차적으로 연산을 처리하는 반면, 프래그먼트 셰이더는 아주 작은 연산을 각 픽셀마다 독립적으로 실행한다. 즉 모든 셀을 순회하는 대신, 픽셀 단위의 계산을 병렬적으로 수행하여 같은 해상도에서 반복 횟수가 늘어도 연산량은 거의 동일하게 유지되어 많은 형태를 그려야 하는 등의 몇몇 상황에서 매우 강력하다.
노이즈
이전까지 살펴본 랜덤 함수는 마치 TV의 백색 잡음처럼 종잡을 수 없는 값의 반환을 목표로 했다. 하지만 구름, 파도, 언덕 등 자연의 무작위함은 이와 사뭇 다르다. 컴퓨터 과학자 Ken Perlin은 1980년대 영화 「트론(Tron)」에서 사용될 텍스처의 제작을 위해 “자연스러운” 난수 생성 함수를 개발하게 된다. 그것이 펄린 노이즈(Perlin Noise)다.
노이즈를 알아보기 전에 mix 함수를 살펴봐야 한다. mix(v1, v2, t)는 v1과 v2를 t만큼 보간한 값을 반환한다. 예를 들어 v1이 1.0, v2가 0.0이고 t가 0.3이라면 0.7을 반환한다. 이는 사실상 아래와 같다.
$$\operatorname{mix}(A,B,t)=A+t(B-A)=(1-t)A+tB$$
『The Book of Shaders』에서는 펄린 노이즈에 앞서 다른 방식으로 생성하는 노이즈를 먼저 살펴본다. 이를 값 노이즈(Value Noise)라고 칭하는데, 개념적으로 펄린 노이즈에 선행하기에 단계적 학습을 위해 먼저 살펴보는 듯하다.
float function(float x){
float ix = floor(x);
float fx = fract(x);
// return hash11(ix);
// return mix(hash11(ix), hash11(ix+1.0), fx);
return mix(hash11(ix), hash11(ix+1.0), smoothstep(0.0, 1.0, fx));
}우선 uv의 x성분을 floor, fract를 이용해 어느 영역인지, 그 영역의 어느 지점인지의 두 부분으로 분리한다. 이후 floor 성분을 random의 입력으로 사용하여 같은 영역에선 같은 무작위 값을 반환하도록 만든다. 셀 경계에서 값이 점프하는데, 이 울퉁불퉁한 값에 인과를 부여하기 위해 mix 함수로 격자점의 난수를 섞는다. 현재 격자점과 다음 격자점의 값을 입력으로 사용하는 것이다. 현재 x가 격자선 어디쯤 있는지는 fract 성분이 알고 있으므로 이를 mix의 t로 사용한다. 이처럼 각 지점에 난수를 부여한 후 그 사이를 보간하면 1차원 노이즈를 만들 수 있다.
펄린 노이즈, 혹은 기울기 노이즈는 값 노이즈에 몇 가지 요소가 추가된다. 우선 각 격자점이 임의의 난수를 가지고 있던 값 노이즈와 달리 각 격자점에 기울기(Gradient)를 부여한다. 여기서 기울기란 이전에 살펴보았던 ‘x가 증가할 때의 y 증가율’, 즉 ‘입력 변화량에 대한 출력 변화량의 비율’과 같은 의미이다. GPT와 대화・학습하며 진행하는데, 이걸 기울기로 받아들이면 헷갈리기 시작한다. 그렇기에 그저 격자점별로 이산적인 방향 벡터를 부여하고, 이 방향 값은 각 좌표에 대한 함수 결과값이 커지는 방향을 나타낸다 생각하기로 했다.
격자점에서 임의의 점 p까지의 벡터, 즉 p가 격자점으로부터 어느 방향으로 얼마나 떨어져있는지를 변위(Displacement)라 칭한다. 각 격자점의 기울기에 변위를 곱해 임의의 점이 기울기에 따라 가지는 값을 연결하면 된다. 현재 격자점의 기울기와 변위, 다음 격자점의 기울기와 변위를 각각 곱한 값을 보간하면 기울기 노이즈(Gradient Noise)를 만들 수 있다.
기울기라는 말이 이해를 방해하는 것 같다. 그냥 “한 칸 이동했더니 값이 어떻게 변했지?”다. 값이 줄었다면 음수, 커지면 양수가 나올 것이다. 값이 음수라면 반대 방향을, 양수라면 같은 방향을 가리키게 된다. 기울기 값 자체가 값이 커지는 방향인 것이다. 이미지는 x, y 두 축에 따라 색상을 가지고 있다. 각 축을 따라 이동할 때 색이 얼마나 달라졌는지를 구하면 2차원 벡터, 즉 색상 값이 커지는 방향이 나온다. 이미지의 정체가 x, y를 입력받아 색을 출력하는 함수임을 새삼 느낄 수 있다. 중요한 건 방금 내용은 이미지가 이미 어떤 출력을 갖는 상태에서 그 출력 값의 기울기를 구한 것이고, 노이즈를 만드는 것은 직접 임의의 기울기를 부여하는 일이라는 것이다. 이를 다른 말로 하면, 노이즈(2차원 기울기 노이즈)는 기울기가 이미지에 선행해 엉뚱한 기울기로 만들어낸 이미지인 셈이다.
2차원 기울기 노이즈는 격자 하나를 이루는 꼭짓점 각각에서 변위와 기울기의 내적을 구하고, 그렇게 구한 값끼리 보간하여 만들 수 있다. 각 격자점에 기울기(단위 벡터)와 변위를 내적하면 변위를 기울기 방향으로 투영한 스칼라 값, 즉 격자 내 임의의 점이 격자점으로부터 기울기 방향으로 얼마나 가 있는지를 나타내는 크기 값을 구할 수 있다. 해당 값을 각각 보간한 것이 노이즈 값이다.
화면을 격자로 나누고, 각 격자점에 무작위한 기울기를 부여하고, 격자점으로부터 격자 내 임의의 점을 향하는 벡터와 기울기를 내적한 뒤, 내적한 값끼리 보간하면 2차원 기울기 노이즈가 된다. 3차원 노이즈는 격자를 큐브 모양으로 입체화하여 계산하면 된다.
// from https://thebookofshaders.com/11/
// Cubic Hermite Curve. Same as SmoothStep()
y = x*x*(3.0-2.0*x);
// Quintic interpolation curve
y = x*x*x*(x*(x*6.-15.)+10.);기울기 노이즈의 보간 방법 또한 시대와 용도에 따라 상이하다. 초기 펄린 노이즈는 에르미트 보간법(Hermite interpolation, 3차 보간 곡선)을 사용했다. 이는 GLSL의 smoothstep 함수에서 사용하는 보간법이다. 이후 펄린은 노이즈를 5차 보간 곡선(Quintic Fade Curve)을 이용하도록 개선하였다. 5차 보간 곡선은 시작과 끝에서 1차 미분뿐 아니라 2차 미분 또한 0이라고 하는데, 1차 미분값은 값의 기울기이고, 2차 미분값은 1차 미분값의 미분값, 즉 값의 변화율이 1차 미분이고, 그 변화율의 변화율이 2차 미분이다. 어쨌건 3차 곡선은 1차 미분값만 0이지만, 5차 곡선은 2차 미분값까지 0이기에 양 끝이 조금 더 부드럽다고 한다. 기억해 뒀다가 뭔가 보간할 때 사용하면 될 것 같다.
Ken Perlin은 2001년 시그라프(Siggraph)에서 기존 노이즈 알고리즘의 성능을 개선한 심플렉스 노이즈(Simplex Noise)를 발표한다. 심플렉스 노이즈는 기울기 노이즈지만, 기존 펄린 노이즈와 달리 심플렉스 격자, 즉 각 차원에서 가장 단순한 격자를 이용한다. 기존 알고리즘이 2차원에서 4개의 격자점을, 3차원에서 8개의 격자점을, 즉 2^n의 격자점을 이용했다면, 심플렉스 노이즈는 2차원에서 3개, 3차원에서 4개의 n+1의 격자점을 이용한다. 그렇기에 고차원 노이즈 연산에 경제적이다.
심플렉스 노이즈를 구현하기 위해선 기존 정사각형 격자를 정삼각형 격자로 변경해야 한다. 기존 격자점을 대각선으로 나누면 이등변직각삼각형 두 개가 그려지는데, 이를 정삼각형 두 개로 이루어진 마름모로 변형하려면 두 대각선 사이의 비율을 바꿔야 한다. uv.x + uv.y를 uv의 각 성분에 더하면, 점들이 y = x의 대각선 방향으로 중심에서 양쪽으로 벌어지게 되고, 형태는 늘어난 좌표계에 비례해 찌그러지게 된다. y = x 선상에서는 값이 크게 증가하게 되고, y = -x 선상에 가까울수록 값의 변화가 줄어든다. 따라서 한 대각선만 늘어나면서 정사각형이 마름모로 변형된다. 정삼각형 두 개로 이루어진 마름모에서 짧은 대각선을 a라고 하면 긴 대각선은 √3a이므로, 두 대각선의 비율이 1:√3이 되도록 이 변형의 세기를 조절하면 된다.
uv += uv.x + uv.y가 잘 와닿지 않았는데, 정규화하지 않은 방향 벡터 (1, 1)과 uv를 내적할 수 있다. 그럼 uv.x * 1 + uv.y * 1, 즉 uv.x + uv.y가 되며, (1, 1)은 둘 다 커지는 방향이기에 이를 uv에 더해준다고 생각하면 된다.
이어서 조금 더 구체적으로, 정삼각형 두 개를 붙이면 한 변의 길이가 마름모를 이등분하는 짧은 대각선이 된다. 그 한 변의 길이를 a라고 할 때, 정삼각형의 높이는 피타고라스 정리상 (√3a)/2, 마름모의 긴 대각선은 √3a가 된다. 즉, 마름모를 가르는 짧은 대각선이 a일 때 긴 대각선은 √3a가 되어야 한다. 기존 늘려지지 않은 사각형 좌표계의 각 대각선은 동일한 길이이기에, 해당 대각선 a에 더해졌을 때 정삼각형 두 개를 합친 마름모의 긴 대각선 길이가 되는 값은 √3a - a이다.
좌표계의 대각선 y = x에서 uv += vec2(uv.x + uv.y)는 현재 위치의 2배만큼 현재 위치에 더해 좌표계를 변형하며, 변형 후 대각선은 3배가, 즉 3a가 되는 셈이다. 그럼 해당 대각선이 √3배가 되도록 더해지는 a2값을 어떻게 변형해야 할지 확인하면, a2 * k = √3a - a, 이를 k에 대해 풀면 k = (√3 - 1) / 2가 된다. 예를 들어 더 단순하게, uv += (uv.x + uv.y)의 변형된 대각선의 길이를 2.4배 하기 위해 uv.x + uv.y에 어떤 값 k를 곱해야 할지 생각하면, uv.x + uv.y는 항상 uv의 각 성분에 각 성분의 2배를 더하고, 길이도 함께 비례하기에 a + 2a*k = a*2.4인 셈이고, 이를 풀면 k = (2.4 - 1.0) / 2가 되어 k는 0.7이 된다. 같은 방식으로 2.4배가 아니라 √3배가 되는 식을 풀면 k는 (√3 - 1.0) / 2인 것이다.
아무튼 좌표계를 y = x 축으로 잡아당기는데, 어느 정도로 잡아당겨야 기존 좌표계의 대각선을 한 변의 길이로 하는 정삼각형 두 개로 이루어진 마름모 좌표계가 만들어지는지의 문제고, 그렇게 만드는 계수 k는 (√3 - 1)*0.5다. 왜 k가 그런 수치가 나오는지 이해가 안 되어 굳이 살펴보았다.
vec2 skew (vec2 st) {
vec2 r = vec2(0.0);
r.x = 1.1547*st.x;
r.y = st.y+0.5*r.x;
return r;
}『The Book of Shaders』에서는 좌표계를 변형하기 위해 위와 같은 함수를 사용한다. 이전 글에서 살펴보았듯 어떤 좌표계 내 벡터 p는 각 축 방향 벡터와 p의 각 축 성분을 계수로 하는 곱의 합으로 아래와 같이 다시 나타낼 수 있다.
$$\vec{p}=p_x(1, 0)+p_y(0, 1)$$
위처럼 각 축의 길이 1의 단위 벡터면서 서로 직교하며 좌표계를 형성하는 요소를 정규직교기저(Orthonormal Basis)라 칭한다. 앞서 좌표계를 변형할 때, 임의의 좌표가 내적을 통해 각 축으로 얼마나 치우쳤는지 구할 수 있었는데, 이는 정규직교기저와 좌표의 내적이 반대축 성분을 포함하지 않기 때문에 가능한 일이다. 다시 말해, 축이 직교하지 않는다면 내적만으로 좌표 성분을 계산하는 것은 불가능하다. 이런 내용을 다루는 것은 앞서 직교좌표계에서 좌표를 늘리는 방법을 사용했지만, 좌표계 자체를 임의로 기울여서 같은 효과를 주는 방법 또한 존재함을 다루기 위해서이며, 위 skew 함수가 바로 그런 방식을 이용한다.
이를 위해 임의의 좌표축을 직접 설정해야 한다. 이를테면 위 skew 함수는 x축을 (1, 0)에서 시계방향으로 30° 회전시킨 약 (0.866, -0.5)의 축을 이용한다. 정삼각형의 한 각은 60°니, y축을 유지하고 120° 벌어지게 만든 것이다. 그러나 위 함수에 0.866의 수치는 보이지 않는다. 이는 기존 직교좌표를 새 좌표계로 변환해야 하기 때문이다. skew 함수는 새 좌표계의 좌표 r을 만든다. 이는 아래와 같이 r의 각 성분을 계수로 사용한 곱의 합으로 나타낼 수 있다.
$$\vec{r}=r_x(0.866,\ -0.5)+r_y(0, 1) \rightarrow (0.866r_x,\ r_y - 0.5r_x)$$
그렇다면 기존 직교좌표 p는 새 좌표계 어디에 위치해있는지 구하면 된다. 여기서 헷갈리는 지점은 uv를 각 기저에 직접 곱하고 더하면 새 좌표계에서의 p를 구할 수 있을 것 같지만 사실은 그렇지 않다는 것이다. 좌표계를 바꾼다는 것은 좌표의 실제 위치를 움직이는 것이 아니라 좌표의 기준을 바꾸는 것에 가깝기 때문이다.
예를 들어 기존 좌표계의 점 p가 (1, 0)이고 이를 그대로 새 기저의 계수로 사용하면 (0.866, -0.5)가 된다. 즉, 기존 좌표의 새 좌표계 위치를 구한 게 아니라, 새 좌표계의 (1, 0)을 기존 좌표계에 재현한 셈이 되는 것이다. 하지만 우리가 원하는 것은 그 반대다. 기존 좌표 p가 새 좌표계로 표현되었을 때 어떻게 되는지 구하려는 것이다. 구체적으로 p가 (0.866, -0.5)라면 새 좌표계에서는 (1, 0)인 셈인데, 우리가 사용할 수 있는 건 기존 직교좌표 p이기에 어떻게 하면 p가 (0.866, -0.5)일 때 새 좌표계의 (1, 0)이 되는지를 알아내야 하는 것이다.
그렇다면 기존 좌표는 유지하며 그 좌표를 나타내는 숫자를 새 좌표에 맞게 바꾸는 방법은 무엇일까. 새 좌표 r이 나타내는 좌표가 기존 좌표 p가 나타내는 좌표와 같게 하는 r을 찾는 것이다. 그렇다면 방정식은 아래와 같고, 이를 r에 대해 풀면 위 skew 함수와 같은 형태가 된다.
$$\vec{p} = (0.866r_x,\ r_y - 0.5r_x)$$
$$\begin{gathered}p_x = 0.866r_x \to r_x = \frac{p_x}{0.866} \qquad p_y = r_y - 0.5r_x \to r_y = p_y + 0.5r_x\end{gathered}$$
그렇다면 직접 skew 함수를 만들 수 있다. 기존 x축을 시계방향으로 30도 돌리지 않고, x축을 60도, y축을 30도 돌린다면, (1, 0) (0, 1)의 기저는 각각 (0.5, -0.866) (0.5, 0.866)이 된다. 식을 만들면 아래와 같다.
$$\vec{p} = r_x(0.5, -0.866) + r_y(0.5, 0.866) = (0.5r_x + 0.5r_y, -0.866r_x + 0.866r_y)$$
$$p_x = 0.5r_x + 0.5r_y \rightarrow r_x = 2p_x - r_y \qquad p_y = -0.866r_x + 0.866r_y \rightarrow r_y = r_x + \frac{p_y}{0.866}$$
$$r_y = 2p_x - r_y + \frac{p_y}{0.866} \rightarrow r_y = p_x + 0.577p_y \qquad r_x = p_x - 0.577p_y$$
vec2 skew(vec2 p){
vec2 r = vec2(0.0);
r.x = p.x - p.y * 0.577;
r.y = p.x + p.y * 0.577;
return r;
}결국 중요한 것은 점 p와 그 점의 좌표는 구분해야 한다는 것이다. 점은 하나의 위치이며, 좌표는 특정 좌표계를 기준으로 그 위치를 표현하는 숫자다. 이를테면 점의 좌표가 (2, 3)라는 것은 필히 원점으로부터 어떤 기저x를 2번, 기저y를 3번 사용한다는 의미다. 보통 정규직교기저 (1, 0), (0, 1)를 기준으로 하기에 (2, 3)을 그대로 사용하지만, 기준이 달라지면 같은 숫자도 다른 위치를 가리킬 수 있다. 따라서 기존 좌표가 (2, 3)인 같은 점을 새 좌표계로 표현하는 것과 새 좌표계에서 좌표가 (2, 3)인 점을 정하는 것은 다르다.
좌표계를 성공적으로 비틀었다면 정삼각형 두 개로 나눠야 한다. 『The Book of Shaders』에서는 이를 시각화하기 위해 무게 중심 좌표계(Barycentric Coordinates)를 사용한다. 이 좌표계가 노이즈를 만드는 데 직접적으로 사용되지는 않는다. 또, 노이즈를 구현하는 것과 그 구현을 시각화하는 것은 사실 별개의 문제다. 지금은 시각화까지 염두에 두고 굳이 무게 중심 좌표계도 알아볼 것이다. 무게 중심 좌표계는 3개의 기준점, 즉 삼각형의 각 정점을 A, B, C로 두었을 때의 삼각형 내부 점의 위치를 삼각형 내부와 경계에서 0 이상이며 합이 1인 세 꼭짓점의 가중합으로 나타낸다. 좌표계를 비틀었지만 fuv는 여전히 0부터 1 사이의 값이다. 즉, fuv.x > fuv.y일 때는 오른쪽 삼각형 (0, 0), (1, 0), (1, 1)이 되는 셈이다. 정점 3개를 각 A(0, 0), B(1, 0), C(1, 1)라고 하고, 삼각형 내 임의의 점 p가 세 꼭짓점을 각각 (a, b, c)만큼 가지고 있다고 할 때, 이는 아래와 같이 나타낼 수 있다.
$$p = Aa + Bb + Cc \ \Rightarrow\ (0, 0)a + (1, 0)b + (1, 1)c$$
따라서 이 세 벡터를 더해서 만들어지는 p는 아래와 같다.
$$p = (b + c,\ c)$$
같은 점을 두 방식으로 표현했으니 각각의 좌표가 같아야 한다.
$$p = (p_x,\ p_y)$$
$$p_x = b+c, \qquad p_y = c$$
각 비율에 대해 식을 풀면 아래와 같다.
$$c = p_y \qquad b = p_x - p_y \qquad a = 1 - p_x$$
혹은 삼각형의 세 꼭짓점을 A, B, C라 할 때, A를 원점으로 하고 AB와 AC 두 변을 기저로 정의되는 2차원 좌표 p로도 나타낼 수 있다. 그렇다면 식은 아래와 같다.
$$p-A = u(B-A) + v(C-A)$$
p에 대해 이를 전개하면 아래와 같다.
$$p = A + uB - uA + vC - vA$$
$$= (1 - u - v)A + uB + vC$$
이는 위에서 살펴본 abc 가중치의 형태와 매우 유사하다. 각 정점의 가중치 계수가 0 이상이고 그 합이 1이라면 다른 방식으로도 나타낼 수 있다. 이를 함수로 나타내면 다음과 같다.
vec3 simplexGrid(vec2 p){
vec3 xyz = vec3(0.0);
if(p.x > p.y){
xyz = vec3(1.0 - p.x, p.x - p.y, p.y);
}else{
xyz = vec3(p.x, p.y - p.x, 1.0 - p.y);
}
return xyz;
}2차원에서 펄린 노이즈는 4개의 격자점에 대한 기울기와 변위의 내적을 x축으로 각각 보간한 후 y축으로 보간하여 노이즈의 높이를 구한다. 반면 심플렉스 노이즈는 보간의 방식이 다르다. 각 격자점에 가중치를 부여해 내적값과 곱한 후 더한다. 여기서 가중치란 무게 중심 좌표계의 그것과는 다르다. 변위 d의 길이를 r이라 하면, 그 제곱은 다음과 같다.
$$r^2 = d_x^2 + d_y^2$$
이에 따른 가중치 t는 다음과 같다.
$$t = R^2 - r^2$$
여기서 R은 각 격자점의 기여가 0이 되는 영향 반경을 의미한다. 따라서 이는 정삼각형 격자에서는 높이와 같다. 변위가 클수록 가중치 t의 값은 줄어들게 된다. 여기서 보통 가중치를 아래와 같이 조정해 사용한다.
$$w = t^4$$
변위가 정점과 가까운 영역을 강하게 남기고 멀어지면 급격하게 눌러버린다. 이를 각 정점의 내적값, 즉 높이 예상치에 곱하고 더하면 각 정점의 높이값을 해당 정점과 가까운 만큼만 반영하게 된다. 정점의 높이 예상치는 정점에서 0이고 정점에서 멀수록 기울기 성분에 따라 커지는데, 이를 적절히 완만하게 조정해주는 것이 가중치인 듯하다. 결과적으로 가중치 식은 아래와 같다.
$$w = max(R^2-r^2, 0.0)^4$$
현재 글에서는 3차원, 4차원 노이즈를 다루지 않았지만, 필요하다면 Patricio Gonzalez Vivo의 GLSL 노이즈 함수를 사용하면 될 것이다.
***
모든 코드는 웹사이트 임베딩을 위해 『The Book of Shaders』의 저자 Patricio Gonzalez Vivo가 제작한 오픈소스 웹 어플리케이션 「glslEditor」로 작성되었다.
다음 글로 이어집니다.









