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

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

float Lerp(float a, float b, float t) {
    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) {
    flat t = InvLerp(iMin, iMax, v);
    return Lerp(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)을 불러와 사용하고 있습니다.

원하는 매쉬에 대한 에니메이션 ZoomIn 함수

먼저 사용하는 함수는 다음과 같습니다. (에니메이션을 위하 GSAP 라이브러리를 사용하였음)

_zoomFit(object3D, viewMode, bFront, viewAngle) {
    const box = new THREE.Box3().setFromObject(object3D);
    const sizeBox = box.getSize(new THREE.Vector3()).length();
    const centerBox = box.getCenter(new THREE.Vector3());
    
    const offset = new THREE.Vector3(viewMode==="X"?1:0, viewMode==="Y"?1:0, viewMode==="Z"?1:0);
    if(!bFront) offset.negate();
    offset.applyAxisAngle(new THREE.Vector3(1,0,0), THREE.Math.degToRad(viewAngle));

    const newPosition = new THREE.Vector3().copy(centerBox).add(offset);
    const halfSizeModel = sizeBox * 0.5;
    const halfFov = THREE.Math.degToRad(this._camera.fov * .5);
    const distance = halfSizeModel / Math.tan(halfFov);
    const direction = (new THREE.Vector3()).subVectors(newPosition, centerBox).normalize();
    newPosition.copy(direction.multiplyScalar(distance).add(centerBox));

    const oldPosition = this._camera.position.clone();
    gsap.to(this._camera.position, { duration: 0.5, x: newPosition.x, y: newPosition.y, z: newPosition.z});

    // camera.lookAt(centerBox.x, centerBox.y, centerBox.z); // OrbitControls를 사용하지 않은 경우
    // this._controls.target.copy(centerBox); // OrbitControls를 사용하고 있는 경우
    gsap.to(this._controls.target, { duration: 0.5,  
        x: centerBox.x, y: centerBox.y, z: centerBox.z});        
}

사용은 줌인 대상을 마우스로 클릭해 선택한다고 할때.. 먼저 RayCaster 객체를 하나 정의하구요.

_setupPicking() {
    const raycaster = new THREE.Raycaster();
    this._divContainer.addEventListener("click", this._onClick.bind(this));
    this._raycaster = raycaster;
}

클릭 이벤트인 _onClick은 다음과 같습니다.

_onClick(event) {
    if(!event.ctrlKey) return false;
    const width = this._divContainer.clientWidth;
    const height = this._divContainer.clientHeight;
    const xy = {x: (event.offsetX / width) * 2 - 1, y: -(event.offsetY / height) * 2 + 1};
    this._raycaster.setFromCamera(xy, this._camera);
    const targets = this._raycaster.intersectObjects(this._scene.children);
    if(targets.length > 0) {
        const mesh = targets[0].object;
        this._zoomFit(mesh, "Y", true, 50);
    }
}

이 애니메이션 ZoomIn 기능을 이용해 만든 단위 기능에 대한 예제 영상은 다음과 같습니다.

분석의 핵심은 시각화다.

먼저 이 글은 사이토 고키의 세번째 딥러닝 서적인 Deep Learning from Scratch에 대한 내용(p225)의 코드의 변형된 내용을 언급하고 있습니다. 이 책은 텐서플로나 파이토치 자체를 구현하기 위한 핵심 내용을 다루고 있습니다. 이 글은 이 책에서 다루는 내용 중 역전파에 대한 시각화를 보여주는 코드를 토대로 어떤 수식에 대한 역전파를 통해 미분값을 계산하는데 수행되는 계산 흐름을 시각화하는 내용이며 시각화에 대한 중요성을 언급합니다.

sin 함수의 미분은 해석적으로 볼 때 cos입니다. 다른 방법으로 테일러 급수를 이용해서도 미분값을 구할 수 있고 코드는 다음과 같습니다.

def my_sin(x, threshold=0.00001):
    y = 0
    for i in range(100000):
        c = (-1) ** i / math.factorial(2 * i + 1)
        c = Variable(np.array(c))
        c.name = "c"
        t = c * x ** (2 * i + 1)
        y = y + t
        if abs(t.data) < threshold:
            break
    return y

위의 sin 값을 얻기 위한 함수를 실행하고, 미분값을 얻기 위한 코드는 다음과 같습니다.

x = Variable(np.array(np.pi/4), "x")  
y = my_sin(x)
y.name = "y"
y.backward()

print(y.data)
print(x.grad)

plot_dot_graph(y, verbose=False, to_file="diagram.svg")

위의 코드가 실행되면 PI/4에 대한 sin 값과 PI/4에 대한 sin 미분값이 얻어집니다. 그리고 미분값을 얻기 위해 계산된 처리 과정이 다음처럼 diagram.svg으로 시각화됩니다.

테일러 급수를 통한 sin 값을 얻기 위한 실제 계산의 흐름은 위의 시각화를 통해 좀 더 이해할 수 있습니다.

시각화는 이처럼 무언가를 좀 더 이해하고, 이러한 과정을 통해 더 깊고 정확하게 이해할 수 있는 기회를 제공합니다. 또한 이러한 이해를 통해 더 나은 방법을 찾을 수 있는 최적화와 그 다음 단계로 나아갈 수 있는 입구를 마련해 줍니다. 분석은 어떤 복잡한 현상을 보다 쉽게 이해하기 위해 작은 것들로 나눠 풀어 놓는 작업이라고 할 때 분석에서 매우 중요한 핵심 도구는 시각화입니다. 시각화가 되지 못한 분석은 타인을 이해 시키기도 힘들 뿐더러 과연 그 분석이 정확히 이뤄졌는지도 확인하기 어렵기 때문입니다.