[shaderPixel] 쉐이더토이 코드 학습 1 - Mandelbulb

나우히즈·2024년 10월 22일

Graphics

목록 보기
16/17

서론

42 프로젝트 중 쉐이더를 다뤄보고, 멋진 이미지로 렌더링해보는 과제가 존재한다. 이 과제를 완성하기 위해선 쉐이더 프로그래밍에 대해 공부해야했고, 이를 위해 쉐이더토이라는 웹 쉐이더 창작물? 사이트에서 쉐이더를 직접 건들여보며 어떤식으로 쉐이더가 구성되고, 어떤 방식으로 픽셀을 찍는지 알아보았다.

이 게시글에서는 프로젝트의 첫 스텝으로 3D fractal 구조물을 렌더링하는 과정을 정리해보려한다.


프로젝트에 기술되기로는,

• A 3D Fractal of your choice (other than the mandelbox), with light, surrounding
occlusion and shadows.

로 되어있다. 기본적으로 렌더링해야하는 쉐이더는 mandelbox라는 3D fractal의 일종이다.

  • mandelbox

위키에서 가져온 이미지로, 위와 같은 형태의 프랙탈을 멘델박스라고 한다.
아무튼 이 멘델박스를 기본적으로 렌더링해야한다.

내가 선택해야할 프랙탈 구조는 이와 유사한 이름의 mandelbulb를 택했다.

이게 mandelbulb의 기본적인 형태이다.
이 이미지는 쉐이더토이에 게시되어있는 멘델볼브 이미지 중 가장 간단한 형태를 배워보면서 glsl 문법에 맞게 바꿔 실행시킨 모습이다.

그럼 작성한 fragment shader 를 먼저 보고 하나씩 뜯어보며 어떤 역할을 하는지 알알보자.


코드

#version 410 core

out vec4 FragColor;

uniform vec2 iResolution;  // 화면 해상도
uniform float iTime;       // 시간

vec3 rotate(vec3 pos, float x, float y, float z) {
    mat3 rotX = mat3(1.0, 0.0, 0.0, 0.0, cos(x), -sin(x), 0.0, sin(x), cos(x));
    mat3 rotY = mat3(cos(y), 0.0, sin(y), 0.0, 1.0, 0.0, -sin(y), 0.0, cos(y));
    mat3 rotZ = mat3(cos(z), -sin(z), 0.0, sin(z), cos(z), 0.0, 0.0, 0.0, 1.0);

    return rotX * rotY * rotZ * pos;
}

float hit(vec3 r) {
    r = rotate(r, sin(iTime), cos(iTime), 0.0);
    vec3 zn = r;
    float rad = 0.0;
    float hit = 0.0;
    float p = 8.0;
    float d = 1.0;
    
    for (int i = 0; i < 10; i++) {
        rad = length(zn);

        if (rad > 2.0) {
            hit = 0.5 * log(rad) * rad / d;
        } else {
            float th = atan(length(zn.xy), zn.z);
            float phi = atan(zn.y, zn.x);
            float rado = pow(rad, 8.0);
            d = pow(rad, 7.0) * 7.0 * d + 1.0;

            float sint = sin(th * p);
            zn.x = rado * sint * cos(phi * p);
            zn.y = rado * sint * sin(phi * p);
            zn.z = rado * cos(th * p);
            zn += r;
        }
    }

    return hit;
}

vec3 eps = vec3(0.1, 0.0, 0.0);

void main() {
    vec2 pos = -1.0 + 2.0 * gl_FragCoord.xy / iResolution;    
    pos.x *= iResolution.x / iResolution.y;

    vec3 ro = vec3(pos, -1.2); // 카메라 위치
    vec3 la = vec3(0.0, 0.0, 1.0); // 카메라 목표
    
    vec3 cameraDir = normalize(la - ro);
    vec3 cameraRight = normalize(cross(cameraDir, vec3(0.0, 1.0, 0.0)));
    vec3 cameraUp = normalize(cross(cameraRight, cameraDir));

    vec3 rd = normalize(cameraDir + vec3(pos, 0.0)); // 광선 방향
    float t = 0.0;
    float d = 200.0;

    vec3 r;
    vec3 color = vec3(0.0);

    for (int i = 0; i < 100; i++) {
        if (d > 0.001) {    
            r = ro + rd * t;
            d = hit(r);
            t += d;    
        }
    }

    // normal 구하기. gradient로 방향을 구한다.
    vec3 n = vec3(
        hit(r + eps) - hit(r - eps),
        hit(r + eps.yxz) - hit(r - eps.yxz),
        hit(r + eps.zyx) - hit(r - eps.zyx)
    );

    vec3 mat = vec3(0.2, 0.5, 0.1); 
    vec3 light = vec3(0.5, 2.5, -2.0);
    vec3 lightCol = vec3(0.6, 0.4, 0.5);
    
    vec3 ldir = normalize(light - r);
    vec3 diff = max(dot(ldir, n), 0.0) * lightCol * 60.0;
    
    color = diff * mat + 0.1 * mat;

    FragColor = vec4(color, 1.0);
}

크게 3가지 파트로 나눌 수 있다.
1. 유니폼 변수 및 in/out 변수
2. hit 함수 (raymarch)
3. main 함수

rotate 함수는 사실 그냥 회전하면서 보여지게 하기 위한 요소인지라 딱히 설명할 게 없다.

유니폼 변수 및 in/out 변수

#version 410 core

out vec4 FragColor;

uniform vec2 iResolution;  // 화면 해상도
uniform float iTime;       // 시간

일단 vs에서 넘겨져오는 변수는 없다. fs에서 raymarch라는 방식을 통해 픽셀들에 해당하는 색상을 결정하여 FragColor로 내보낸다.

화면상의 비율을 조절해주기 위해 iResolution 이라는 화면 해상도 값을 받아오고, 시간에 따라 회전을 구현하기 위해 시간값을 유니폼으로 받는다.

main 함수

void main() {
    vec2 pos = -1.0 + 2.0 * gl_FragCoord.xy / iResolution;    
    pos.x *= iResolution.x / iResolution.y;

    vec3 ro = vec3(pos, -1.2);
    vec3 la = vec3(0.0, 0.0, 1.0);
    
    vec3 cameraDir = normalize(la - ro);
    vec3 cameraRight = normalize(cross(cameraDir, vec3(0.0, 1.0, 0.0)));
    vec3 cameraUp = normalize(cross(cameraRight, cameraDir));

    vec3 rd = normalize(cameraDir + vec3(pos, 0.0)); // 광선 방향

pos에 먼저 각 픽셀에 해당하는 위치를 잡는다.
gl_FragCoord / iResolution 는 [0-1] 범위를 갖게 된다. 이를 NDC 에 맞게 [-1,1] 범위로 바꿔주기 위해 두배 곱 후 1을 뺀다.

pox.x에 해상도 값을 곱하여 비율을 맞춰주는데, 이 이유는 NDC 자체가 가로세로 -1 ~ 1 의 정사각형이라고 가정되기 때문이다. 일반적으로 화면의 비율이 가로가 길다보니, 그에 맞게 가로로 해상도 값을 곱하여 왜곡되는 것을 막아주자는 취지.
예를 들어, 해상도가 1920x1080인 경우 가로가 세로보다 더 길기 때문에, iResolution.x / iResolution.y는 1보다 큰 값을 가진다. 이를 통해 x축을 더 길게 만들어 비율을 맞춤.

이후 ro, la 값을 정한다. (0, 0, 1)에 존재하는 카메라가 각 픽셀로 광선을 발산한다.이 때 각 픽셀의 위치는 앞서 정의한 pos에 z값을 합하여 생성한 vec3 변수가 된다.

입출력 장치를 통해 상호작용이 이뤄지게 설정은 나중에 해보도록 할거고, 지금은 카메라의 속성에 해당하는 벡터들을 고정적으로 세팅하겠다.

...
    float t = 0.0;
    float d = 200.0;
    vec3 r;
    vec3 color = vec3(0.0);

    for (int i = 0; i < 100; i++) {
        if (d > 0.001) {    
            r = ro + rd * t;
            d = hit(r);
            t += d;    
        }
    }
...

이 중간부분은 raymarch 의 핵심적인 부분이다.
각 변수들은,

  • t: 광선이 진행한 길이
  • d: 현재 위치에서 사물까지의 거리
  • r: 광선의 원점에서 rd방향으로 t만큼 진행한 위치의 점

정도로 이야기할 수 있겠다.

레이마칭은 카메라로부터 스크린 픽셀들을 향해 레이를 전진시키고(Ray Marching), 해당 픽셀의 레이가 오브젝트 표면에 닿으면 그 픽셀에 오브젝트 표면을 렌더링하는 방식이다.

이 코드에서는 광선이 진행하면서 현재 위치에서 가장 가까운 오브젝트와의 거리가 hit(r)을 통해 계산되어 d에 담기면, 광선을 rd방향으로 다시 d만큼 진행하고 거기서 가장 가까운 오브젝트와의 거리를 hit 함수를 통해 찾는다. 이 과정을 100번 반복하는데 반복하더라도 d값이 0.001 이하로 내려가지 않으면 광선이 물체와 만나지 않고 쭉 진행한다고 판단하여 기본색을 배정한다.

하지만 d < 0.001이 되면 물체와 부딪혔다고 판단하여 그에 알맞는 색을 입힌다.

    // normal 구하기. gradient로 방향을 구한다.
    vec3 n = vec3(
        hit(r + eps) - hit(r - eps),
        hit(r + eps.yxz) - hit(r - eps.yxz),
        hit(r + eps.zyx) - hit(r - eps.zyx)
    );

    vec3 mat = vec3(0.2, 0.5, 0.1); 
    vec3 light = vec3(0.5, 2.5, -2.0);
    vec3 lightCol = vec3(0.6, 0.4, 0.5);
    
    vec3 ldir = normalize(light - r);
    vec3 diff = max(dot(ldir, n), 0.0) * lightCol * 60.0;
    
    color = diff * mat + 0.1 * mat;

    FragColor = vec4(color, 1.0);
}

여기서의 법선벡터는 gradient를 활용하여 구한다. 각 방향으로의 거리함수의 미묘한 차이를 두고 gradient를 근사적으로 구한 뒤 활용한다.

이후 빛에대한 세팅을 진행하고 컬러값을 구해서 Fragcolor가 리턴되게 한다.

hit 함수

그러면 어떤식으로 레이마칭이 거리값을 구해내게 되는지 좀 더 자세히 살펴보도록 하자.

hit 함수는 주어진 3차원 벡터 r에 대해 프랙탈 표면과의 거리를 계산하는 Distance Estimator (DE) 함수 역할을 한다. 프랙탈의 특징적인 재귀적 변환을 통해 거리를 근사하는 방식입니다.

  1. 입력 및 초기화
r = rotate(r, sin(iTime), cos(iTime), 0.0);
vec3 zn = r;
float rad = 0.0;
float hit = 0.0;
float p = 8.0;
float d = 1.0;
  • r : 입력으로 들어온 3차원 좌표. 회전함수를 곱해서 회전된 모습이 보이게 된다.
  • zn : r의 복사본으로, 이후 프랙탈 변환에서 사용.
  • rad : zn 벡터의 크기(거리)를 저장하는 변수.
  • hit : 거리를 저장하는 변수로, 최종적으로 반환됨.
  • p : 프랙탈의 “파워”를 나타내며, 멘델불브(Mandelbulb)에서 자주 사용하는 8.0으로 설정.
  • d : 거리 추정에 사용되는 변환 변수입니다. 이 값은 프랙탈 변환 과정에서 축적됩니다.
  1. 프랙탈 반복 루프
for (int i = 0; i < 10; i++) 
{
    rad = length(zn);
    
    if (rad > 2.0) 
    {
        hit = 0.5 * log(rad) * rad / d;
    } 
    else 
    {
        float th = atan(length(zn.xy), zn.z);
        float phi = atan(zn.y, zn.x);
        float rado = pow(rad, 8.0);
        d = pow(rad, 7.0) * 7.0 * d + 1.0;
        
        float sint = sin(th * p);
        zn.x = rado * sint * cos(phi * p);
        zn.y = rado * sint * sin(phi * p);
        zn.z = rado * cos(th * p);
        zn += r;
    }
}

이 루프는 10회 반복되며, 프랙탈 변환을 수행하고 거리를 업데이트합니다. 각 단계는 아래와 같습니다:

  • rad = length(zn)
    • 현재 위치 zn에서 원점까지의 거리를 계산합니다.
    • 프랙탈의 특징인 “확장 및 변환”을 기반으로 거리를 추적합니다.
      `
  • 탈출 조건
    • if (rad > 2.0): 만약 현재 좌표 zn의 거리가 2.0보다 크면 더 이상 변환하지 않고, 프랙탈 표면에서 충분히 멀어진 것으로 간주합니다.
    • 이 경우, Distance Estimator로 hit = 0.5 * log(rad) * rad / d를 계산해 hit 값에 저장한다.
    • 여기서 log(rad)는 거리의 로그 값을 이용해 프랙탈의 복잡한 표면을 더 세밀하게 반영하는 효과를 줍니다.
      `
  • 프랙탈 변환:
    • else 블록에서는 프랙탈 변환이 이루어집니다. 이 과정은 멘델불브(Mandelbulb)와 같은 다차원 프랙탈을 정의하는 주요 부분입니다.
    • 먼저, 구면 좌표 변환을 수행합니다:
    • th = atan(length(zn.xy), zn.z)는 z축과 벡터 사이의 각도(세타)를 구합니다.
    • phi = atan(zn.y, zn.x)는 x-y 평면에서의 각도(파이)를 구합니다.
    • 그 후, 거리 rad의 8제곱을 계산하여 rado로 저장하고, 이를 이용해 프랙탈 변환을 적용합니다.
    • d 값 업데이트: d는 거리 추정을 위한 값으로, 각 반복마다 계속 업데이트됩니다. pow(rad, 7.0) 7.0 d + 1.0은 변환 과정에서 rad의 7제곱에 비례하여 업데이트됩니다.
    • 구면 좌표에서의 변환:
    • zn.x, zn.y, zn.z는 각각 sint, cos 함수와 함께 사용되어 좌표를 재구성합니다.
    • 이 과정을 통해 원점 근처에서 프랙탈 표면을 점차적으로 확장시키고, 다시 자기 자신으로 더해집니다(zn += r).
      `

결과


바이러스같은 모습을 한 멘델볼브 완성이다.

갑자기 생각이 들었는데 수학적으로 대칭적인 형태를 가지는 이런 구조가 자연계에서 발생하여 존재할 수 있지 않을까 싶다.

이 밖에 추가적으로 쉐이더토이를 보면서 추가해보면 좋을 사항들에 대해 추가해볼 생각이다! 끗

1개의 댓글

comment-user-thumbnail
2024년 10월 22일

로마네스코(브로콜리)

답글 달기