HTMLElement를 통한 사용자 정의 컴포넌트 개발시 자식 접근

먼저 다음과 같이 HTMLElement를 상속받아 사용자 정의 컴포넌트를 생성합니다. 이 소스는 외부 js 파일에 존재합니다.

class TEST extends HTMLElement {
    constructor() {
        super();
    }

    connectedCallback() {
        console.log(this.querySelectorAll("div"));
    }
}

customElements.define("my-test", TEST);

이제 위의 코드가 담긴 js 파일을 다음 웹 페이지에 포함시킬 수 있습니다.


웹 페이지에서 다음처럼 정의한 사용자 컴포넌트를 태그를 통해 추가할 수 있구요.


    

종국에는 TEST 클래스 코드의 7번 줄에 의해 my-test 태그의 자식 div가 2개이므로 콘솔에 아래처럼 출력되어야 합니다.

NodeList(2) [div, div]

하지만 NodeList []로 표시됩니다. 즉, 자식을 발견할 수 없다가 됩니다. 이유는 웹페이지의 모든 DOM 구성 트리가 완성되지 않은 상태에서 js 파일이 실행되었기 때문인데요. 정상적으로 처리하기 위해서 js 파일의 실행을 DOM 구성 트리가 완성되기 전까지 지연(defer)시켜줘야 합니다. 아래처럼 js 파일을 웹 페이지에 포함시키는 코드를 수정하면 됩니다.


동기화 문제인데.. 이런 동기화 처리를 위한 defer 사용은 껄끄럽습니다만.. 여튼 이렇게 하면 자식 요소에 접근 가능하게 됩니다.

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 기능을 이용해 만든 단위 기능에 대한 예제 영상은 다음과 같습니다.