GLSL 코드와 Blender의 쉐이더 노드

쉐이더 프로그래밍 언어 중에 하나인 GLSL의 프레그먼트 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하는 내용에 대한 정리입니다. 먼저 GLSL에 대한 코드는 다음과 같습니다.

varying vec2 vUv;

void main() {
  float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.3));
  gl_FragColor = vec4(vec3(strength), 1.0);
}

결과는 다음과 같습니다. three.js를 사용했습니다.

위의 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하면 다음과 같습니다.

쉐이더의 코드는 블렌더의 쉐이더 노드로 구성할 수 있고, 그 반대로도 가능합니다. 쉐이더 코드는 매우 함축적이고 작은 코드 변화에도 그 결과는 예상하기 어려운 경우가 있습니다. 하지만 블렌더의 쉐이더 노드를 통해 먼저 그 결과를 만들고 이를 다시 쉐이더 코드로 변환한다면 좀 더 나은 개발 접근이 될 수 있습니다.

dFdx와 dFdy를 이용한 법선 벡터 계산

버텍스 쉐이더에서 정점을 흔들었을 경우 법선 벡터 역시 다시 계산을 해줘야 하는데, 이때 정점의 x와 y에 대한 편미분 값을 얻을 수 있는 dFdx와 dFdy를 이용하면 법선 벡터를 얻을 수 있습니다.

이런 경우 버텍스 쉐이더에 전달된 normal을 vary를 통해 프레그먼트 쉐이더로 전달할 필요가 없고 프레그먼트 쉐이더에서 법선 벡터를 계산해 주면 되는데, 프레그먼트 쉐이더에서 이에 대한 코드는 다음과 같습니다.

vec3 normal = normalize(
    cross(
        dFdx(vPosition.xyz),
        dFdy(vPosition.xyz)
    )
);

위의 vPosition은 버텍스 쉐이이더에서 재계산된 정점인데, 버텍스 쉐이더의 코드에서 보면 다음과 같습니다.

varying vec3 vPosition;

void main() {	
    vec3 posClone = position;
    posClone = /* 정점 흔들기(변경) */
    
    ...

    vPosition = modelMatrix * vec4(posClone, 1.0)).xyz;

    ...
}

적용 결과로 비교하면 먼저 dFdx와 dFdy를 통한 법선 벡터를 사용하지 않고 지오메트리를 통해 제공되는 법선 벡터를 그대로 사용한 경우는 아래와 같습니다.

버텍스 쉐이더에서 정점을 흔들어서 원래 제공된 법선 벡터가 맞지 않아 음영 효과가 제대로 표현되지 않는데, 이를 개선하기 위해 dFdx와 dFdy를 통한 법선 벡터를 사용한 결과는 다음과 같습니다.

R3F에서 Shader를 통한 Material

먼저 다음처럼 drei의 shaderMaterial를 이용해 GLSL로 재질을 만들 수 있습니다.

import { shaderMaterial } from '@react-three/drei'

const WaveShaderMaterial = shaderMaterial(
  {
    uColor: new THREE.Color(1, 0, 0)
  },

  /* glsl */`
    varying vec2 vUv;

    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,

  /* glsl */`
    uniform vec3 uColor;
    varying vec2 vUv;

    void main() {
      gl_FragColor = vec4(vUv.y * uColor, 1.0);
    }
  `
)

리엑트는 선언형 프로그래밍(?) 방식을 권장하므로 위에서 만든 WaveShaderMaterial을 Tag처럼 선언해서 사용할 수 있도록 해야 합니다. 이때 R3F의 extend가 사용됩니다.

import { Canvas, extend } from '@react-three/fiber'

extend({ WaveShaderMaterial })

실제 사용은 다음과 같습니다.

const MyCanvas = () => {
  return (
    <Canvas>
      <pointLight position={[10,10,10]} />
      <mesh>
        <planeGeometry args={[5,5]} />
        <waveShaderMaterial uColor={"white"} />
      </mesh>
    </Canvas>
  )
}

function App() {
  return (
    <MyCanvas />
  )
}

결과는 다음과 같습니다.

Lerp, InvLerp, Remap 함수 코드 및 three.js에서의 적용

언어는 C#인가 C인가.. Java인가.. 다 해당되는거 같기도 한데.. 여튼 워낙 기초 코드로 작성된 함수이니 저장해 두고 three.js의 쉐이더 작성 코드에서 적용한 예를 살펴봅니다.

float Lerp(float a, float b, float t) { // 내장 함수 mix와 동일
    return (1.0f - t) * a + b * t;
}

float InvLerp(float a, float b, float v) {
    return (v-a) / (b-a);
}

float Remap(float iMin, float iMax, float oMin, float oMax, float v) {
    float t = InvLerp(iMin, iMax, v);
    return Lerp(oMin, oMax, t);
}

만약 Remap 함수만 필요할 경우 다음 코드를 사용하면 됩니다.

float remap(float iMin, float iMax, float oMin, float oMax, float v) {
    float t = (v-iMin) / (iMax-iMin);
    return mix(oMin, oMax, t);
}

위의 코드는 Shader 중 glsl 언어로도 사용되는데요. 저 같은 경우 three.js에서 사용한 경우를 소개해 봅니다. 참고로 three.js은 WebGL 기술을 랩핑한 js 라이브러리입니다.

Shader는 Vertex와 Fragment에 대한 처리가 있고.. 재질(Material)에 지정되는데요. 다시 재질은 적용될 지오메트리(Geometry)가 필요합니다. 다음은 지오메트리와 재질에 대한 코드입니다.

const geometry = new THREE.BoxGeometry(1, 1, 1, 10, 10, 10);

fetch("shader4.glsl").then(response => {
    return response.text();
}).then(text => {
    const fragmentShaerCode = text;
    const material = new THREE.ShaderMaterial({
        side: THREE.DoubleSide,
        transparent: true,

        uniforms: {
            iTime: { value: 0 },
            iResolution:  { value: new THREE.Vector3() },
        },

        vertexShader: `
            uniform float iTime;    
        
            varying vec2 vUv;

            void main() {
                vUv = uv;
                gl_Position = projectionMatrix * modelViewMatrix  * vec4(position,1.0);
                //gl_Position = projectionMatrix*(modelViewMatrix*vec4(0.,0.,0.,1.)+vec4(position.x,position.y,0.,0.)); // <- billboard
            }
        `,
        
        fragmentShader: fragmentShaerCode
    });

    const cube = new THREE.Mesh(geometry, material);
    this._scene.add(cube);

    this._material = material;

    ....
}).catch(function (error) {
    console.warn(error);
});

fragmentShaerCode는 shader4.glsl 파일로부터 불러와지는데, 해당 코드는 다음과 같습니다.

uniform vec3 iResolution;
uniform float iTime;

varying vec2 vUv;

float rect(vec2 uv, vec2 p0, vec2 p1, float blur) {
    float maskX = smoothstep(p0.x-blur, p0.x+blur, uv.x);
    maskX -= smoothstep(p1.x-blur, p1.x+blur, uv.x);

    float maskY = smoothstep(p0.y-blur, p0.y+blur, uv.y);
    maskY -= smoothstep(p1.y-blur, p1.y+blur, uv.y);

    float mask = maskX * maskY;
    return mask;
}

// t = a -> return 0., t = b -> return 1.
float invLerp(float a, float b, float t) {
    return (a - t) / (a - b);
}

float remap(float a, float b, float c, float d, float t) {
    return invLerp(a, b, t) * (d - c) + c;
}

void main() {
    vec2 cUv = vUv;

    cUv -= .5;
    float x = cUv.x;

    float m = sin(iTime+x*8.) * .1;
    float y = cUv.y - m;

    float blur = remap(-.5, .5, .01, .25, x);
    blur = pow(blur*3., 3.);
    float mask = rect(vec2(x, y), vec2(-.5,-.1), vec2(.5,.1), blur);

    vec3 col = vec3(1., 1., 0.) * mask;
    gl_FragColor = vec4(col, 1. ); 
}

추후 이 내용을 제 스스로도 참조하기 위해 재질에 대한 uniforms 데이터를 지정하고 있는데요. 관련 코드는 다음과 같습니다.

update(time) {
    time *= 0.001; // second unit
    this._material.uniforms.iTime.value = time;
}

resize() {
    const width = this._divContainer.clientWidth;
    const height = this._divContainer.clientHeight;

    ...

    this._material.uniforms.iResolution.value.set(width, height, 1);
}

위의 코드에 대한 실행 결과는 다음과 같습니다.

위의 예제는 YouTube의 The Art Of Code의 영상을 참조하여 three.js에 맞게 적용한 것입니다.

쉐이더(Shader) 코드를 외부 자원으로 분리하기

Shader 코드를 외부 자원으로 분리해서 좀 더 깔끔하게 코드를 작성하고자 합니다. 물론 이런 분리는 유지보수 및 Shader에 대한 분리를 통해 유연성을 증가시키는 장점도 있습니다. Shader 코드를 shader.glsl이라는 파일로 작성했다고 할 때, 이 파일 자원을 불러와 사용하는 코드의 예시는 다음과 같습니다.

fetch("shader.glsl").then(response => {
    return response.text();
}).then(text => {
    const fragmentShaerCode = text;
    const material = new THREE.ShaderMaterial({

        ...

        vertexShader: `
            uniform float iTime;    
        
            varying vec2 vUv;

            void main() {
                vUv = uv;
                gl_Position = projectionMatrix * modelViewMatrix  * vec4(position,1.0);
            }
        `,
        
        fragmentShader: fragmentShaerCode
    });

    ....

}).catch(function (error) {
    console.warn(error);
});

vertex Shader는 분리되어 있지 않았지만 fragment Shader는 분리된 파일 자원(여기서는 shader.glsl)을 불러와 사용하고 있습니다.