웹개발 자료실/three.js 프론트개발 Code

「three.js」RayCaster, 3D오브젝트와 마우스 이벤트 연결하기

Recstasy 2023. 1. 12. 22:28

 

마우스로 3D 오브젝트를 조작하는 방법은 의외로 복잡하다. x, y 좌표값만 있는 2차원과 달리 3D는 z축이라는 '깊이값'이 존재하기 때문이다. 사용자가 3D 오브젝트를 클릭하는 과정에는 x, y 좌표값을 계산한 후에 깊이값을 추출하는 과정이 숨어 있다. 만일 three.js가 제공하는 API를 사용하지 않는다면, 결코 그 계산이 쉽지 않다.

 

다행스럽게도 three.js는 z축의 깊이값을 'raycaster()'클래스로 간단하게 해결한다. 단,  'mouse'의 좌표값을 반환하는 clientX, clientY, offsetX, offsetY, pageX, pageY 등...과 같은 개념을 확실하게 알고 있어야만 "사용자 반응 3D앱"을 제작할 수 있다. 

 

 

 

 

 

 


| init()함수 

container는 DOM element가 지정된다. 이후, camera, scene, light, geometry와 함께 Mesh를 1000개 가량 랜덤으로 생성해준다.

 

import * as THREE from 'three';
import { OrbitControls } from 'https://firebasestorage.googleapis.com/v0/b/webgame-786ab.appspot.com/o/lib%2FthreeJS%2Fr148%2Fcontrols%2FOrbitControls.js?alt=media&token=c77b322e-f388-46e1-9fad-23fc495843f7';

let camera, scene, raycaster, renderer, light, control;
let mouse = new THREE.Vector2(), SELECTED;
let radius = 100, theta = 0;
let container = document.getElementById( 'webdolRayCaster' );

init();
animate();

function init() {
        
        camera = new THREE.PerspectiveCamera( 70, container.clientWidth / container.clientHeight, 1, 10000 );
        camera.position.set( 0, 20, 100 );
        
        scene = new THREE.Scene();
        
        light = new THREE.DirectionalLight( 0xffffff, 1 );
        light.position.set( 1, 1, 1 ).normalize();
        scene.add( light );

        let geometry = new THREE.BoxBufferGeometry( 20, 20, 20 );

        for ( let i = 0; i < 1000; i ++ ) 
        {
            let grey = Math.random();
            let object = new THREE.Mesh( 
                geometry, 
                new THREE.MeshLambertMaterial({ 
                    color: new THREE.Color( grey, grey , grey )  
                })
            );

            object.position.x = Math.random() * 800 - 400;
            object.position.y = Math.random() * 800 - 400;
            object.position.z = Math.random() * 800 - 400;
            object.rotation.x = Math.random() * 2 * Math.PI;
            object.rotation.y = Math.random() * 2 * Math.PI;
            object.rotation.z = Math.random() * 2 * Math.PI;
            object.scale.x = Math.random() + 0.5;
            object.scale.y = Math.random() + 0.5;
            object.scale.z = Math.random() + 0.5;
            scene.add( object );
        }

        renderer = new THREE.WebGLRenderer();
        renderer.setClearColor( 0xf0f0f0 );
        renderer.setPixelRatio( container.devicePixelRatio );
        renderer.setSize( container.clientWidth, container.clientHeight );

        // renderer.sortObjects = false;

        container.appendChild( renderer.domElement );
		
        control = new OrbitControls( camera, renderer.domElement );
        control.update();

        raycaster = new THREE.Raycaster();

        container.addEventListener( 'mousemove', onDocumentMouseMove, false );
        container.addEventListener( 'mousedown', onDocumentMouseDown, false );
        container.addEventListener( 'resize', onWindowResize, false );

    }

 

 

위에서 중요한 부분은 rayCaster()다. rayCaster()를 선언하기 전에 반드시 '라이트'가 있어야 한다는 점에 주의하자. 라이트의 광원과 오브젝트의 경계가 곧 z값(깊이값)이므로 three.js는 raycaster()로써 z값을 반환한다. 이로써 rayCaster()는 반드시 라이트가 필요하다.(camera.position값이 없는 경우, OrbitControl이 작동하지 않는 것과 같은 원리)

 

그리고 사용자의 입력이 이뤄지는 DOM, container에는 onDocumentMouseMove와 onDocumentMouseDown 이벤트 함수를 지정한다. 

 

 

 

 

 


| 이벤트 설정

 // 마우스 이벤트 설정
    function onDocumentMouseMove( event ) {

        event.preventDefault();

        let gapX = event.clientX - event.offsetX;
        let gapY = event.clientY - event.offsetY;
        
        mouse.x = ((event.clientX - gapX)/( container.clientWidth )) * 2 - 1;
        mouse.y = -((event.clientY - gapY)/( container.clientHeight )) * 2 + 1;

    }
            
    function onDocumentMouseDown( event ) {

        event.preventDefault();
        if ( SELECTED )
        {
            SELECTED.currentHex = 0x00ff00 * Math.random();
            SELECTED.material.emissive.setHex( SELECTED.currentHex );
        }

    }

 

 

onDocumentMouseMove()함수는 gapX, gapY값이 필요하다. gapX, gapY값은 웹브라우저에서 사용자가 클릭하는 DOM영역 외부의 공백이다. 아래 그림을 보자.

 

위의 그림에서 녹색 부분은 3D가 랜더링 되는 영역이다. 즉, 녹색 영역에서 마우스 클릭이 이뤄질 때마다 event.clientX와 event.clientY값이 발생한다. 그런데 event.clientX, event.clientY값은 웹브라우저의 원점(좌측상단 0, 0)으로부터의 거리다. 따라서 사용자가 클릭한 좌표의 영역이 DOM(녹색 영역)에서 어느 정도 차지하는지 그 비율을 정확히 계산할 수 없다.(외부의 공백 때문에)

 

rayCaster()는 녹색 영역의 중앙을 원점(0, 0)으로 계산한다. 즉, 웹브라우저의 원점(좌측 상단)을 사용자 이벤트 DOM의 중앙으로 변환해야 한다. 이와 같은 공식은 아래와 같다.

 

 

mouse.x = ( (event.clientX - gapX) / (DOM.clientWidth) ) * 2 - 1;

mouse.y = - ( (event.clientY - gapY) / (DOM.clientHeight) ) * 2 + 1;

 

 

DOM은 녹색 영역을 차지하는 DOM을 의미하며, gapX, gapY는 각각 "event.clientX - event.offsetX", "event.clientY - event.offsetY" 값이다. 이로써 아래와 같이 x, y축으로 각각 -1 ~ 1의 비율로 전환할 수 있다.

 

 

 

또, onDocumentMouseDown 이벤트는 SELECTED 객체를 확인 후, Math.random()함수를 통해 오브젝트의 색상을 지정한다. 

 

 

 

 

 


| render()

function render() {
        // find intersections
        raycaster.setFromCamera( mouse, camera );

        let intersects = raycaster.intersectObjects( scene.children );

        if ( intersects.length > 0 ) {
            if ( SELECTED != intersects[0].object ) 
            {

                if ( SELECTED ) SELECTED.material.emissive.setHex( SELECTED.currentHex );

                SELECTED = intersects[0].object;
                SELECTED.currentHex = SELECTED.material.emissive.getHex();
                SELECTED.material.emissive.setHex( 0xff0000 );
                container.style.cursor = 'pointer';

            }
        } 
        else 
        {
            if ( SELECTED ) 
            {
                SELECTED.material.emissive.setHex( SELECTED.currentHex );
                SELECTED = null;
                container.style.cursor = 'auto';
            }
        }

        renderer.render( scene, camera );

    }
    

function animate() {
        requestAnimationFrame( animate );
        control.update();
        render();
  }

 

 

랜더함수의 핵심은 'SELECTED' 객체다. three.js의 raycaster에서 intersectObjects()메서드는 라이트의 광원과 부딪친 오브젝트를 반환한다. 이를 위해 반드시 카메라와 마우스의 거리값을 계산해줘야 한다.

 

*raycaster.setFromCamera(mouse,camera)

 

.setFromCamera( mouse, camera )는 raycaster를 활성화 하며, 이로써 intersects에서 광원과 부딪친 모든 객체들이 하위 객체(children)로 생성된다. 그리고 'intersects.length'값이 0보다 크다면 아래 조건을 확인한다.

 


1) 마우스와 반응하는 오브젝트 확인 

2) 오브젝트가 있을 경우, 기존의 선택한 오브젝트인지 체크

3) 현재 색상값 지정하기 


 

현 프로젝트는 테스트용일 뿐, rayCaster()는 GLTF, FBX 로더를 통해 각종 커스텀 오브젝트와 결합할 때 빛을 발한다. 사용자의 반응이 없다면 사실상 3D 영상과 같기 때문에 웹3D 개발자라면 필수적으로 알고 넘어갈 수 밖에 없는 영역이다.(로더와 결합한 여러 실험용 프로젝트 추천)