Three.js 오브젝트 드래그를 통해 위치 이동 기능 구현

ohaeseong·2024년 11월 13일
0

마우스 위치 가져오기

일반적으로 마우스 위치를 가져 올 때는 clientX, clientY를 가져와서 사용한다. 하지만 3D 공간 내에서는 이러한 2D 차원의 좌표를 별도로 3D 차원의 좌표로 바꾸어 줘야 할 필요성이 있다.

또한 clientX, clientY를 그대로 사용하지 못하는 이유 중 다른 것은 canvas의 크기 및 비율 적 문제이다. clientX, clientY는 각각 왼쪽에서 마우스가 얼마나 떨어져 있는지, 위 쪽에서 마우스가 얼마나 떨어져 있는 지를 pixel 값으로 반환 하는데 이 값은 모니터의 해상도 마다, 크기 마다 다르며 canvas의 크기가 항상 모니터를 꽉 차게 두지는 않기 때문에 clientX, clientY 값을 정규화 시켜 줄 필요가 있다.

이러한 작업을 가능케 해주는 것이 Three.js의 raycaster 함수이다.

간단한 Raycaster의 작동 방식

  1. Raycaster 함수는 카메라를 기준으로 특정의 가상 광선을 생성한다. 이 광선은 화면상에서 마우스 위치에 해당하는 방향으로 쏘아 진다.

  2. 마우스 위치를 3D 공간으로 변환
    마우스 위치 (clientX, clientY) 값을 -1, 1 사이의 정규화 된 값으로 변환 한다. (이 범위는 Three.js에서 사용하는 좌표계에 맞추기 위함)
    이렇게 정규화 된 좌표 값을 Raycaster 함수의 인자 값으로 전달 한다.

  3. 교차점 계산
    Ray함수에서 내보내는 가상의 광선이 화면 상 어떤 3D 객체와 교차하면 intersections 라는 결과 배열 값을 생성하는데 여기서 가상 광선이 만난 모든 객체에 대한 정보가 담긴다. 일반적으로 교차점 중 interections[0] 값이 마우스와 객체의 거리가 가장 좁으니 이 값을 마우스가 가르키는 3D 객체의 좌표로 결정 한다.

    위 단계를 거쳐서 마우스의 좌표 값을 3D 좌표계 기준에 맞춰 변환 할 수 있는 것이다.

마우스 드래그를 통해 오브젝트 위치를 이동하는 코드 예제

const [isDragging, setIsDragging] = React.useState<boolean>(false);

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);

useFrame(({ camera }) => {
	if (!isDragging || !ref.current) return;
	
	raycaster.setFromCamera(mouse, camera);
	const intersectPoint = new THREE.Vector3();

	if (raycaster.ray.intersectPlane(plane, intersectPoint)) {
	  ref.current.position.set(
		intersectPoint.x,
		intersectPoint.y,
		intersectPoint.z,
	  );
	}
});

useEffect(() => {
	if (isDragging) {
	  window.addEventListener("mousemove", handleMouseMove);
	  window.addEventListener("pointerup", handlePointerUp);
	} else {
	  window.removeEventListener("mousemove", handleMouseMove);
	  window.removeEventListener("pointerup", handlePointerUp);
	}
	
	return () => {
	  window.removeEventListener("mousemove", handleMouseMove);
	  window.removeEventListener("pointerup", handlePointerUp);
	};
}, [isDragging]);

return (
	<primitive
	  ref={ref}
	  object={drc.scene}
	  scale={scale}
	  onPointerDown={handlePointerDown}
	/>
);

function handleMouseMove(event: MouseEvent) {
	if (isDragging) {
	  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
	  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
	}
}

function handlePointerUp() {
	setIsDragging(false);
	document.body.style.cursor = "default";
}

function handlePointerDown(e: ThreeEvent<PointerEvent>) {
	setIsDragging(true);
	document.body.style.cursor = "grabbing";
	
	if (typeof onPointerDown === "function") {
	  onPointerDown(e);
	}
}

handleMouseMove 함수 계산 공식 설명

  1. Three.js에서는 좌, 우 -1, 1 그리고 상, 하 -1, 1의 범위를 가지는 좌표계 시스템을 가지고 있다.

  2. mouse의 clientX, clientY 값은
    모니터의 맨 왼쪽 기준에서 마우스가 얼마나 떨어져 있는지 혹은 모니터의 맨 위쪽 기준에서 마우스가 얼마나 떨어져 있는 건지를 나타내는 값이며 가장 맨 왼쪽, 맨 위쪽은 각 각 0의 값에서 시작 한다.

    예:)
    모니터의 가장 왼쪽:0,
    모니터의 가장 오른쪽: window.innerWidth
    모니터의 가장 위쪽: 0
    모니터의 가장 아래쪽: window.innerHeight

  3. 기본적으로 이러한 client 좌표 값은 pixel 값으로 이루어져 있으며 모니터의 크기, 해상도에 따라 마우스가 움직이는 범위의 값이 달라지며 좌표의 값도 달라 진다.

  4. 위 3번의 이유로 인해 특정 범위, 0 ~ 1 사이에서 모니터의 종류에 상관 없이 좌표의 값이 나타날 수 있도록 정규화 작업이 필요 하다.

  5. 0에서 1 사이의 값으로 정규화 하기 위해 (마우스의 좌표 값 / 모니터 좌우, 상하의 최대 값) 이런식으로 계산해주면 현재 마우스의 좌표 값이 모니터 밖으로 나갈 수는 없으므로 1이상의 값을 가지지 못하며 같은 이유로 0이하의 값을 가지지 못한다.

  6. 이렇게 0 ~ 1 사이의 값으로 좌표 값을 구해주고 나면 해당 값을 Three.js 좌표계 시스템에 맞도록 계산 해주어야 하는데 이 값은 -1 ~ 1 사이의 값으로 표현 해야 한다.

  7. 6번의 조건을 충족 시켜주기 위해서 (좌표 값 / 최대 값)에서 나온 값에 x 2를 해주고 - 1를 해준다. 이는 x 좌표 값을 기준으로 한 것인데, 설명 하자면 0 - 1 사이에 값에서 중앙을 (0, 0) 값으로 해주기 위해서는 나온 값에 2를 곱해주고 - 1을 해주어야 좌표가 중앙으로 왔을 때 0이 되도록 기준을 맞출 수 있다.

    여기서 y 값은 x 좌표 값과 동일하게 계산 하되 계산하여 나온 결과 값에는 음수 부호를 붙여 준다. 여기서 음수 부호를 붙여 주는 이유는 x 값은 왼쪽에서 오른쪽으로 값이 증가 하는 형태가 브라우저와 three.js가 동일하지만 y 값의 경우 브라우저는 아래로 갈 수록 값이 늘어나고 three .js 시스템에서는 아래로 갈 수록 값이 떨어지기 때문에 브라우저 증가 형태를 기준으로 계산한 값은 three.js에서는 값이 늘어나도 실제로는 줄어들어야 함으로 음수 부호를 붙여주는 것이다.

마우스 move 이벤트를 Canvas가 아닌 3D 객체 컴포넌트에서 받는 이유

여기서 canvas element란 모든 3D 객체를 감싸는 역할을 한다.

원래 처음에 작업 했을때에는 마우스의 clientX, clientY 값을 가져오기 위해서 canvas element 내에 있는 onMouseMove 이벤트에서 값을 가져 왔다. 하지만 수정 후에는 마우스 좌표 값을 3D 객체 컴포넌트 내에서 window 객체에 있는 mouse move 이벤트를 등록 해서 좌표 값을 가져 와주었는데 이에 대한 이유는 아래와 같다.

  1. 컴포넌트가 특정 기능을 외부에서 받지 않고 컴포넌트 자체로 기능 할 수있도록 모듈화 하기 위해
  2. canvas 영역에서만 드래그 기능이 작동하지 않고 모니터 전체를 기준으로 기능 작동이 되도록 하기 위해
  3. 추가 설명) 여기서 3D 객체 컴포넌트는 다양한 3D 모델을 렌더링 할 수 있는 컴포넌트이다.

만약 canvas 내에서만 작동하고 싶고, 딱히 컴포넌트로 빼고 싶지 않다고 하면 clientX, clientY 값은 window에서 나오는 값이나 onMouseMove에서 나오는 값이나 같기 때문에 canvas 이벤트를 사용해도 문제 없다

Three.js 함수 설명

const raycaster = new THREE.Raycaster(); 
const mouse = new THREE.Vector2(); 
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
  • raycaster = 가상의 광선을 통해 마우스 좌표 값을 3D 공간에서 사용할 수 있도록 변환해주는 함수
  • THREE.Vector2 = 마우스 좌표 값을 2D Vector 값으로 변환 시켜주는 함수 2D로 변환 해준후 raycaster 함수로 전달한다.
  • plane = 수평 평면을 기준으로 오브젝트를 움직이기 위해 THREE.Plane 함수 선언, 여기서 THREE.Vector3(0, 1, 0)은 Y 값을 1로 고정한 평면임을 정해준다.
useFrame(({ camera }) => { 
	if (!isDragging || !ref.current) return; 
	
	raycaster.setFromCamera(mouse, camera); 
	const intersectPoint = new THREE.Vector3(); 

	if (raycaster.ray.intersectPlane(plane, intersectPoint)) { 
		ref.current.position.set( 
			intersectPoint.x, 
			intersectPoint.y, 
			intersectPoint.z, 
		); 
	} 
});
  • useFrame = react fiber 라이브러리의 함수로써 각 프레임 마다 콜백 함수를 실행한다. 주로 3D 렌더링 중 주기적으로 함수를 실행할때 사용하며 이번의 경우 각 프레임 마다 마우스 위치로 오브젝트를 이동 시키는 용도로 사용한다.
  • raycaster.setFromCamera = 마우스의 좌표를 기준으로 광선이 나가도록 설정 하는 함수, 해당 함수를 통해서 오브젝트와 마우스의 상호작용이 가능하도록 한다. 여기서 마우스 좌표 값은 Three.js 좌표계 시스템을 기준으로 -1 ~ 1의 값을 가진다. 마우스의 위치는 항상 바뀌기 때문에 오브젝트를 드래그한 위치로 이동 시키기 위해서는 마우스의 위치 값을 항상 3D 공간에서 트래깅할 필요가 있다. 그래서 프레임 마다 마우스의 위치에서 광선을 설정 해준다. 여기서 camera 인자가 필요한 이유는 현재 바라보고 있는 카메라 화면을 기준으로 3D 좌표를 생성하기 때문에 카메라가 없으면 3D 좌표 값을 변환하지 못한다. 즉 좌표 화면의 기준 역할을 하는 것이 camera 인자의 역할이다.
  • THREE.Vector3() = 해당 함수는 Three.js에서 3차원 상의 공간의 위치를 저장 하기 위한 클래스 함수이다.
  • intersectPoint = 여기서 intersectPoint 상수 값의 역할은 3차원 공간의 위치를 저장하기 위한 용도 이며 마우스에서 나오는 광선과 평면 (plane)과의 교차 지점이 어디인지를 저장하기 위해 사용된다.
  • raycaster.ray.intersectPlane(plane, intersectPoint) = 해당 함수는 앞서 프레임 별로 마우스의 위치에 설정한 광선이 plane과 교차하는지를 확인 하는 함수이며 만약 마우스 광선의 위치가 평면과 교차 한다면 교차하는 해당 3차원 위치를 intersectPoint에 저장하고 true를 반환한다. 여기서 Y 값이 고정이 된것이가? 에 대한 질문이 있다면 intersectionPoint의 Y 값자체는 고정이 아니긴 하지만 plane과 마우스 광선이 만나는 교차 지점 자체가 Y값이 1로 고정되어 있기 때문에 프레임 별로 항상 같은 Y 값을 가지게 되므로 실질 적으로 업데이트 되는 좌표 값은 x, z 값 뿐이다.
  • 여기서 들 수 있는 의문이 어째서 평면의 객체와 마우스의 가상 광선이 만나는 지점에 내가 옮기려는 3D 객체가 있다는걸 알 수 있는 거지? 일 수 있는데 여기서 isDragging이 true가 아닐 경우에는 애초에 위와 같은 내용은 실행 되지 않으며 isDragging이 true인 경우는 오직 사용자가 오브젝트를 마우스로 드래그 했을때 뿐이기 때문에 마우스의 위치에 항상 오브젝트가 있는 상태라고 이해하면 된다. 그렇기 때문에 마우스로 드래그 하고 있고 또 마우스의 광선이 plane의 가상 평면과 교차 하고 있다면 오브젝트 위치 값이 Y 값은 고정 된채로 움직일 수 있기 때문에 기능이 작동 하는 것이다.

0개의 댓글