다양한 노이즈 함수 알아보기

0

이 글은 Value, Perlin, Simplex 노이즈와 같이 잘 알려진 노이즈 함수의 기본 원리를 정리하고, 이를 사용하는 방법을 설명한다.

Value 노이즈

Value 노이즈는 각 정점마다 네 개의 난수 값을 사용하는 가장 간단한 노이즈 함수다. 여기1에서 언급했듯이, GLSL은 진짜 난수 함수를 지원하지 않고 유사 난수 함수만 지원한다. 따라서 한 정점은 인덱싱, 즉 random(x + vec2(1., 0.))을 통해 인접한 정점의 난수 값을 읽을 수 있다. 덕분에 네 정점의 값을 보간하여 노이즈 값을 생성할 수 있다. 경계에서 노이즈 값을 부드럽게 만들기 위해 smoothstep()이나 커스텀 타이밍 함수를 사용할 수 있다. 보간 방법은 다음 글(/2024-09-21-interpolation-methods/)에서 자세히 다룬다.

// Fragment
precision mediump float;

varying vec2 v_position;

float random (in vec2 x) {
    return fract(sin(dot(x, vec2(12.9898,54.233))) * 43758.5453123);
}

float noise (in vec2 x) {
    vec2 i = floor(x);
    vec2 f = fract(x);

    float tl = random(i); // top-left corner
    float tr = random(i + vec2(1.0, 0.0)); // top-right corner
    float bl = random(i + vec2(0.0, 1.0)); // bottom-left corner
    float br = random(i + vec2(1.0, 1.0)); // bottom-right corner

    vec2 u = smoothstep(0., 1., f);

    return
    mix(
        mix(tl, tr, u.x), 
        mix(bl, br, u.x), 
    u.y);
}

void main() {
    vec2 st = v_position * 6.;
    float value = noise(st);
    
    gl_FragColor = vec4(vec3(value), 1.);
}

노이즈 함수의 입력, st 의 스케일을 키울수록 노이즈 패턴의 해상도가 증가한다. 그러나 결과가 매우 벽돌 (bricky) 처럼 보이며, 이 한계는 다른 보간 방법으로도 해결되지 않는다.

Perlin 노이즈

Perlin 노이즈는 블록 형태의 패턴을 줄이면서 자연스러운 복잡성을 표현하기 위해 Ken Perlin이 제안하였다. Value 노이즈와 달리, Perlin 노이즈는 각 정점에서 무작위 벡터를 생성한다. 먼저 인접한 네 정점의 그래디언트 벡터를 계산한다. 둘째, 각 그래디언트 벡터와 해당 정점에서 후보 점으로 향하는 오프셋 벡터의 내적을 계산한다. 이 내적의 값은 꼭지점에서 0이 되며, 모서리에서 XY 평면과 교차하고 법선 벡터가 n = (g_x, g_y, -1)(여기서 (g_x, g_y)는 그래디언트 벡터)인 평면의 z 값을 나타낸다. 후보 점의 최종 값은 이 네 개의 내적 값을 보간하여 얻는다. 마지막으로 0.5를 곱하고 0.5를 더하여 값을 정규화할 수 있다. 하나의 셀에 대해 각 그래디언트 벡터의 내적 값을 그려 보고, 정점의 그래디언트 벡터와 노이즈 함수의 벡터장을 비교해 보자. 색상은 -5(파랑)부터 5(주황)까지의 범위를 가진다.

top-left top-right
bottom-left bottom-right
그래디언트 벡터와 노이즈 결과 그래디언트 벡터장

예제 코드는 아래와 같다.

// Fragment
precision mediump float;

varying vec2 v_position;

#define PI (3.141592)

float random (in vec2 x) {
    return fract(sin(dot(x, vec2(12.9898,54.233))) * 43758.5453123);
}

vec2 random2 (in vec2 x) {
    float theta = random(x) * 2. * PI;
    return vec2(cos(theta), sin(theta));
}

float Perlin (in vec2 x) {
    vec2 i = floor(x);
    vec2 f = fract(x);

    vec2 a = random2(i);
    vec2 b = random2(i + vec2(1., 0.));
    vec2 c = random2(i + vec2(0., 1.));
    vec2 d = random2(i + vec2(1., 1.));

    float va = dot(a, f);
    float vb = dot(b, f - vec2(1.,0.));
    float vc = dot(c, f - vec2(0.,1.));
    float vd = dot(d, f - vec2(1.,1.));

    vec2 u = smoothstep(0., 1., f);

    return 0.5 + 0.5 * mix(mix(va, vb, u.x), mix(vc, vd, u.x), u.y);
}

void main() {
    vec2 st = v_position * 6.;
    float value = Perlin(st);
    
    gl_FragColor = vec4(vec3(value), 1.);
}

그러나 노이즈의 값이 그래디언트 벡터로부터 계산되므로 모든 꼭지점의 값은 0이다. 이러한 Perlin 노이즈의 특성은 방향성 아티팩트 (directional artifact) 를 일으킬 수 있다. 즉, 조건에 따라 값이 0에 가까운 직선이 보일 수 있다. 이 문제를 해결하기 위해, 그래디언트 벡터의 크기를 무작위로 설정하거나,

vec2 random2 (in vec2 x) {
    float magnitude = random(x * 12.34);
    float theta = random(x) * 2. * PI;
    return magnitude * vec2(cos(theta), sin(theta));
}

앞서의 Value 노이즈를 함께 사용할 수 있다.

void main() {
    vec2 st = v_position * 6.;
    float value = 0.2 * noise(st) + 0.8 * Perlin(st);
    
    gl_FragColor = vec4(vec3(value), 1.);
}

Simplex 노이즈

Simplex 노이즈는 자연스러운 복잡성을 유지하면서 방향성 아티팩트와 계산 복잡도를 줄이기 위해 Ken Perlin이 제안하였다. 위의 노이즈 함수들에서 보았듯이, 사각형 기반의 셀은 보간을 계산하기 쉽다. 하지만 Ken Perlin은 2D 형태의 기본 구조가 삼각형이라고 생각했다. 또한 사면체(삼각뿔)는 3D 형태 중 가장 단순한 구조이다. 노이즈의 셀로 삼각형을 사용하면 세 개의 난수 값을 계산해 보간하는 것으로 충분한 반면, 사각형에서는 네 개의 값을 사용해야 한다. 3D 공간에서는 사면체에서 네 번, 정육면체에서 여덟 번의 계산이 필요하다. 따라서 n차원 공간에서 Simplex 노이즈의 계산 복잡도는 (n+1)개의 n차원 그래디언트 벡터로 인해 \(O(n\cdot(n+1))\)인 반면, Perlin 노이즈의 계산 복잡도는 \(O(n\cdot2^n)\)이다. Simplex 노이즈를 구현하는 데에는 네 단계가 있다: 좌표 기울이기 (coordinate skewing), 심플렉스 분할 (simplicial subdivision), 그래디언트 선택 (gradient selection), 커널 합산 (kernel summation).2

좌표 기울이기

\(n\)차원 입력 좌표는 아래 공식으로 변환된다.

\[\begin{align} &x' = x + (x+y+\cdots) \cdot F,\\ &y' = y + (x+y+\cdots) \cdot F,\nonumber\\ &\cdots,\nonumber \end{align}\]

여기서

\[F = \frac{\sqrt{n+1}-1}{n}\]

이다. 역변환 공식은 아래와 같다.

\[\begin{align} &x = x' - (x'+y'+\cdots) \cdot G,\\ &y = y' - (x'+y'+\cdots) \cdot G,\nonumber\\ &\cdots,\nonumber \end{align}\]

여기서

\[G = \frac{1-1/\sqrt{n+1}}{n}\]

이다. 좌표는 \((0,0,\cdots,0)\)과 \((1,0,\cdots,0)\) 사이의 거리가 \((0,0,\cdots,0)\)과 \((1,1,\cdots,1)\) 사이의 거리와 같아질 때까지 주대각선 방향으로 압축된다. 위 공식들을 유도해 보자.

위 그림은 입력 좌표 \(\{O\}\)와 기울어진 좌표 \(\{S\}\)를 보여준다. 점 \(x'\)에 대해,

\[{}^O {\bf x'} = {}^O T_S \cdot {}^S {\bf x'},\]

이며, 여기서 \({}^O T_S\)는 기울어진 좌표의 점을 입력 좌표로 변환하는 변환 행렬이다. 그리고 이 행렬은 하나의 변수로 다음과 같이 표현할 수 있다.

\[\begin{equation} {}^O T_S = \left[\begin{matrix} 1-G & -G & \cdots & -G \\ -G & 1-G & \cdots & -G \\ \cdots & \cdots & \ddots & \vdots \\ -G & -G & \cdots & 1-G \\ \end{matrix}\right] \end{equation}\]

그러면 기울어진 좌표는 다음을 만족해야 한다.

\[||{}^O {\bf 0} - {}^O {\bf u'}|| = ||{}^O {\bf 0} - {}^O {\bf x'}||\]

이는 기울어진 좌표에서 다음과 같이 표현된다.

\[||{}^O T_S {}^S {\bf 0} - {}^O T_S {}^S {\bf u'}|| = ||{}^O T_S {}^S {\bf 0} - {}^O T_S {}^S {\bf x'}||\]

그러면 \({}^S {\bf u'} = (1,0,\cdots,0)^T\)이고 \({}^S {\bf x'} = (1,1,\cdots, 1)^T\)이므로

\[||(1-G, -G, \cdots, -G)^T|| = ||(1-nG,1-nG,\cdots,1-nG)^T||\]

가 만족된다. 위 식으로부터,

\[\begin{align} (1-G)^2 + (n-1) \cdot G^2 &= (1-n \cdot G)^2 \cdot n \\ 1 - 2\cdot G + G^2 + (n-1)\cdot G^2 &= n - 2 n^2 G + n^3 G^2 \nonumber\\ (n^3-n)\cdot G^2-2\cdot(n^2-1)\cdot G+(n-1) &= 0 \nonumber\\ n(n+1)\cdot G^2 - 2\cdot (n+1) \cdot G + 1 &= 0. \nonumber\\ \end{align}\]

따라서,

\[\begin{align} G &= \frac{(n+1)-\sqrt{(n+1)^2 - n(n+1)}}{n(n+1)} \nonumber\\ &= \frac{1-1/\sqrt{n+1}}{n} \end{align}\]

이다. 역변환 공식을 계산하기 위해 \({}^S T_O\)를 다음과 같이 정의하자.

\[\begin{equation} {}^S T_O = \left[\begin{matrix} 1+F & F & \cdots & F \\ F & 1+F & \cdots & F \\ \cdots & \cdots & \ddots & \vdots \\ F & F & \cdots & 1+F \\ \end{matrix}\right] \end{equation}\]

이 행렬은 임의의 \(n\)차원 점 \({}^S {\bf x}=(x_0, x_1, \cdots, x_{n-1})^T\)에 대해

\[{}^S T_O \cdot {}^O {\bf x} = {}^S {\bf x}\]

를 만족해야 한다. 그러면 \({}^O {\bf x}\)는 \({}^O T_S\)에 의해 아래와 같이 유도된다.

\[\begin{equation} {}^O {\bf x} = \left(\begin{matrix} x_0 - (x_0 + x_1 + \cdots + x_{n-1}) \cdot G \\ x_1 - (x_0 + x_1 + \cdots + x_{n-1}) \cdot G \\ \vdots \\ x_{n-1} - (x_0 + x_1 + \cdots + x_{n-1}) \cdot G \\ \end{matrix}\right) \end{equation}\]

점 \(\bf{x}\)의 \(i\)번째 원소에 주목하면, 아래가 만족된다.

\[\begin{align} x_i &= (x_i - (\Sigma_i x_i) \cdot G)\cdot (1+F) + \Sigma_{j\neq i} \left(x_j - (\Sigma_i x_i)\cdot G\right)\cdot F \\ x_i &= x_i + F \cdot \Sigma_i x_i - (\Sigma_i x_i)\cdot G\cdot (1+F) - (n-1)\cdot (\Sigma_i x_i) \cdot G \cdot F \nonumber \nonumber\\ 0 &= F \cdot \Sigma_i x_i - (\Sigma_i x_i)\cdot G - n \cdot (\Sigma_i x_i)\cdot G \cdot F \nonumber\\ 0 & = F - G - n \cdot G \cdot F \nonumber\\ \end{align}\]

따라서,

\[\begin{align} F &= \frac{G}{1-n \cdot G} \\ &= \frac{\sqrt{n+1} - 1}{n}. \nonumber \\ \end{align}\]

이다. 셰이더에서 x_skew는 기울어진 좌표에서의 점, x_orig는 입력 좌표에서 해당 셀의 원점, x0은 셀 내부의 내부 좌표를 나타낸다.

const float F = 0.5 * (1.7320508076 - 1.);
const float G = 0.5 * (1. - 1./1.7320508076);
  
vec2 x_skew = st + dot(vec2(F), st);
vec2 i0 = floor(x_skew);
vec2 x_orig = i0 - dot(i0, vec2(G));
vec2 x0 = st - x_orig;

Simplex 분할

아래 그림은 3차원 좌표에서 총 여섯 개의 simplex를 보여준다. \(n!\)개의 조합 중 \({}^S (0,0,\cdots,0)\)과 \({}^S (1,1,\cdots,1)\)을 포함하는 simplex는 \(n\cdot(n-1)\)개다.

x > y > z y > x > z z > x > y
x > z > y y > z > x z > y > x

임의의 점에 대해, 각 원소의 크기 순서에 따라 그 점이 어느 simplex에 속하는지 판단할 수 있다. 아래 코드에서는 2차원 좌표에 두 개의 simplex가 있으므로 x0.xx0.y의 크기만 비교했다. 그러면 입력 좌표에서의 위치 i1i1 - vec2(G)이고, 입력 좌표에서의 위치 i2i2 - 2.0 * vec2(G)이다. 따라서 x1x2는 한 정점과 인접 simplex 정점 사이의 벡터를 나타낸다.

vec2 i1 = vec2(0.0);
if (x0.x > x0.y) {
    i1 = vec2(1.0, 0.0);
} else {
    i1 = vec2(0.0, 1.0);
}
// i2 = vec2(1.0)

vec2 x1 = x0 - (i1 - vec2(G));
vec2 x2 = x0 - (vec2(1.0) - 2.0 * vec2(G));

그래디언트 선택

위의 고전적인 Perlin 노이즈와 마찬가지로, 각 정점에 대해 유사 난수 그래디언트 벡터를 생성한다.

vec2 g0 = random2(i0);
vec2 g1 = random2(i0 + i1);
vec2 g2 = random2(i0 + vec2(1.0));

커널 합산

마지막으로, 아래와 같이 각 정점의 기여를 합산하여 외삽된 값을 계산한다.

vec3 t = max(0.5 - vec3(dot(x0,x0), dot(x1,x1), dot(x2,x2)), 0.0);
t = t*t*t*t;

return 50.0 * dot(t, vec3(dot(x0, g0), dot(x1, g1), dot(x2, g2))) + 0.5;

전체 코드는 다음과 같다.

precision mediump float;

varying vec2 v_position;

#define PI (3.141592)

float random (in vec2 x) {
    return fract(sin(dot(x, vec2(12.9898,54.233))) * 43758.5453123);
}

vec2 random2 (in vec2 x) {
    float magnitude = random(x * 142.214);
    float theta = random(x) * 2. * PI;
    return magnitude * vec2(cos(theta), sin(theta));
}

float simplex (vec2 st) {
    const float F = 0.5 * (1.7320508076 - 1.);
    const float G = 0.5 * (1. - 1./1.7320508076);

    vec2 x_skew = st + dot(vec2(F), st);
    vec2 i0 = floor(x_skew);
    vec2 x0 = st - (i0 - dot(i0, vec2(G)));

    vec2 i1 = vec2(0.0);
    if (x0.x > x0.y) {
        i1 = vec2(1.0, 0.0);
    } else {
        i1 = vec2(0.0, 1.0);
    }
    vec2 x1 = x0 - i1 + vec2(G);
    vec2 x2 = x0 - vec2(1.0) + 2.0 * vec2(G);

    vec2 g0 = random2(i0);
    vec2 g1 = random2(i0 + i1);
    vec2 g2 = random2(i0 + vec2(1.0));

    vec3 t = max(0.5 - vec3(dot(x0,x0), dot(x1,x1), dot(x2,x2)), 0.0);
    t = t*t*t*t;

    return 30.0 * dot(t, vec3(dot(x0, g0), dot(x1, g1), dot(x2, g2))) + 0.5;
}

void main() {
    vec2 st = v_position * 6.;
    float value = simplex(st);
    
    gl_FragColor = vec4(vec3(value), 1.);
}

결과는 다음과 같다.