기본적인 셰이더 디자인 패턴 다루기

0

이제 셰이더로 표현할 커스텀 패턴을 디자인할 차례다. 모든 정점과 프래그먼트는 서로에 대해 “눈이 멀어” 있으므로, 동시성 프로그래밍과는 다른 방식으로 코드를 작성해야 한다. 따라서 정점의 위치와 색상은 개별 속성과 전역 uniform 값만으로 정의해야 한다. 여기서는 셰이더의 기본적인 몇 가지 기법에 대해 다룬다. 더 자세한 내용은 https://thebookofshaders.com을 참고하길 바란다.

기본 함수

  • step(th, x): \(\rm{th} \it < x\)이면 \(1\), 그렇지 않으면 \(0\)을 반환한다.
  • smoothstep(th1, th2, x): \(x < \rm{th}_1\)이면 \(0\), \(\rm{th}_2 \it < x\)이면 \(1\), 그 외에는 [0, 1] 사이로 부드럽게 보간된 값을 반환한다.
  • 수학 연산
    • abs(x): 절댓값 \(\|x\|\)을 반환한다.
    • sin, cos, tan, ...: 삼각함수
    • min(x, y): 더 작은 값을 반환한다.
    • max(x, y): 더 큰 값을 반환한다.
    • pow(x, y): \(x\)의 \(y\)제곱 값을 반환한다. \(~x^y\)
    • dot(v, w): 벡터 \(v\)와 \(w\)의 내적을 반환한다. \(~v \cdot w\)
    • cross(v, w): 벡터 \(v\)와 \(w\)의 외적을 반환한다. \(~v \times w\)
    • mod(x, y): 나눗셈 \(x/y\)의 나머지를 반환한다. \(~x - y * \rm floor(\it x/y \rm)\)
    • fract(x): \(x\)의 소수부를 반환한다. \(~x - \rm floor(\it x \rm)\)
  • mix(x, y, r): 가중치 \(r\)로 \(x\)와 \(y\) 사이를 보간한 값을 반환한다.
  • clamp(x, th1, th2): \(x\)를 \(\rm{th}_1\)과 \(\rm{th}_2\) 사이로 잘라낸 값, 즉 \(\rm min(max( \it x, \rm{th}_1), \rm{th}_2)\)를 반환한다.
  • length(v): 벡터 \(v\)의 유클리드 길이를 반환한다. \(~\|v\|\)
  • distance(v, w): 벡터 \(v-w\)의 유클리드 길이를 반환한다. \(~\|v-w\|\)

위의 기본 함수들을 이용해 아래에서 몇 가지 기법을 다룬다.

그라디언트

그라디언트 색상은 여러 색상 사이의 전환을 보여준다. 그라디언트 색상을 구현하려면 \(n\)개의 색상과 \((n-1)\)개의 가중치 변수가 필요하다. mix를 사용하면 간단한 그라디언트를 쉽게 구현할 수 있다.

varying vec2 v_position; // [0, 1]

void main() {
    vec3 color1 = vec3(1.0, 0.5, 0.5);
    vec3 color2 = vec3(0.5, 1.0, 1.0);
    gl_FragColor = vec4(mix(color1, color2, v_position.x), 1.0);
}

또는 smoothstep을 사용하여 그라디언트 경계의 위치를 조정할 수 있다.

varying vec2 v_position; // [0, 1]

void main() {
    vec3 color1 = vec3(1.0, 0.5, 0.5);
    vec3 color2 = vec3(0.5, 1.0, 1.0);
    float ratio = smoothstep(0.3, 0.7, v_position.x);
    gl_FragColor = vec4(mix(color1, color2, ratio), 1.0);
}

또한 보간 함수를 직접 설계할 수도 있다. 부드러운 보간 방법에 대한 자세한 내용은 보간 방법을 참고하길 바란다.

varying vec2 v_position; // [0, 1]

float my_interp(in float a, in float b, in float x) {
    float z = (x-a)/(b-a);
    return z < 0.5 ? 2.0 * z * z : 1.0 - pow(-2.0 * z + 2.0, 2.0) / 2.0;
}

void main() {
    vec3 color1 = vec3(1.0, 0.5, 0.5);
    vec3 color2 = vec3(0.5, 1.0, 1.0);
    float ratio = my_interp(0.0, 1.0, v_position.x);
    gl_FragColor = vec4(mix(color1, color2, ratio), 1.0);
}

세 가지 색상으로 그라디언트를 생성하려면, 두 개의 가중치 변수와 함께 mix를 두 번 사용한다.

varying vec2 v_position; // [0, 1]

void main() {
    vec3 color1 = vec3(1.0, 0.5, 0.5);
    vec3 color2 = vec3(0.5, 1.0, 1.0);
    vec3 color3 = vec3(0.2, 0.5, 1.0);
    float ratio1 = smoothstep(0.0, 0.6, v_position.x);
    float ratio2 = smoothstep(0.4, 1.0, v_position.x);
    gl_FragColor = vec4(mix(mix(color1, color2, ratio1), color3, ratio2), 1.0);
}

반복 패턴

   

대부분의 복잡한 자연 텍스처를 축소해서 보면 패턴화되어 있음을 알 수 있다. [0, 1] 범위를 여러 개의 [0, 1]로 나누기 위해 fract() 함수를 사용한다.

varying vec2 v_position; // [0, 1]

void main() {
    float ncol = 3.0;
    float nrow = 2.0;
    float x1 = fract(ncol * v_position.x);
    float y1 = fract(nrow * v_position.y);
    float idx_x1 = floor(ncol * v_position.x);
    float idx_y1 = floor(nrow * v_position.y);

    gl_FragColor = vec4(x1, y1, (idx_x1 + idx_y1) / 5.0, 1.0);
}

마찬가지로 패턴 안에 또 다른 패턴을 만들고 싶다면 격자를 두 번 나눈다.

varying vec2 v_position; // [0, 1]

vec4 divide_cell(in vec2 parent, in vec2 size) {
    vec4 child = vec4(0.0);
    child.xy = fract(size * parent); // x, y position
    child.zw = floor(size * parent); // x, y index
    return child;
}

void main() {
    vec4 xy1 = divide_cell(v_position, vec2(3.0, 2.0));
    vec4 xy2 = divide_cell(xy1.xy, vec2(2.0, 2.0));

    gl_FragColor = vec4(xy2.x, xy2.y, mod(xy2.z + xy2.w, 2.0), 1.0);
}

난수 (Random)

안타깝게도 GLSL은 진짜 난수 생성기를 제공하지 않는다. 대신, 고진폭 사인 함수와 fract를 조합하여 유사 난수를 생성할 수 있다. fract는 값을 [0, 1] 범위로 제한하고, 사인 함수는 [0, 1] 구간마다 서로 다른 기울기를 만들어 무작위성을 생성한다.

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

2차원 이상의 벡터로부터 난수를 생성하려면, 내적을 이용해 스칼라 값을 만든다.

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

위의 매직 넘버는 무작위성이 잘 나타나도록 임의로 선택할 수 있다. 난수 수열의 분석에 대한 고찰은 난수 생성기의 무작위성 확인하기를 참고하길 바란다.

노이즈 (Noise)

서로 무작위적으로 독립적인 값들의 순열을 random이라고 부른다면, noise는 random 값들 사이를 보간하여 이웃끼리 비슷한 값을 가지는 순열을 의미한다. 따라서 noise 함수는 연속성을 가지는 반면, random 함수는 불연속성을 보인다. 아래는 random과 noise의 예시다.

random noise

앞서 언급했듯이 모든 정점과 프래그먼트는 서로에 대해 눈이 멀어 있으므로, 각 프래그먼트는 주변의 색상을 읽을 수 없다. 그러나 GLSL의 random 함수는 진짜 난수가 아니라 유사 난수를 생성하므로, 각 프래그먼트가 인접한 프래그먼트의 값을 읽을 수 있다. 고급 노이즈 함수에 대한 자세한 내용은 노이즈 함수를 참고하길 바란다.

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

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

    // Four corners in 2D of a tile
    float a = random(i);
    float b = random(i + vec2(1.0, 0.0));
    float c = random(i + vec2(0.0, 1.0));
    float d = random(i + vec2(1.0, 1.0));

    // Cubic Hermine Curve. Same as SmoothStep()
    vec2 u = smoothstep(0., 1., f);

    // Mix 4 coorners percentages
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

현실 세계에서 신호는 여러 주파수를 가진 사인파들의 합이며, 푸리에 변환으로 특정 주파수의 사인파 진폭을 계산할 수 있다. 노이즈 패턴에서는 분할 수가 푸리에 변환의 주파수에 대응한다. 따라서 서로 다른 분할 크기의 노이즈를 여러 개 더하면 자연스러운 텍스처를 흉내 낼 수 있다. 이를 프랙탈 노이즈 또는 프랙탈 브라운 노이즈라고 한다.

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);

    // Mix 4 coorners percentages
    return mix(mix(tl, tr, u.x), mix(bl, br, u.x), u.y);
}

#define LEVELS 6
float fractal_noise (in vec2 x, in float scaling_amp, in float scaling_freq) {
    float n = 0.;
    float amplitude = 1. - scaling_amp; // ensure that the maximum value is 1.

    
    for (int i = 0; i < LEVELS; ++i) {
        n += amplitude * noise(x);
        amplitude *= scaling_amp; 
       x *= scaling_freq;
    }

    return n;
}

void main() {
    gl_FragColor = vec4(vec3(fractal_noise(2.*v_position, 0.5, 2.)), 1.);
}

또한 여러 프랙탈 노이즈와 시간 변수를 조합하면 연기와 같이 흐르는 텍스처를 표현할 수 있다.

void main() {
    vec3 color = vec3(0.5, 0.6, 0.7);

    float n = fractal_noise(2.*v_position, 0.5, 2.);
    float m = fractal_noise(2.*v_position + vec2(0.1, 0.12)*u_time + n, 0.5, 2.);

    gl_FragColor = vec4(mix(vec3(0.), color, m), 1.);
}