일반적으로 마우스 위치를 가져 올 때는 clientX, clientY를 가져와서 사용한다. 하지만 3D 공간 내에서는 이러한 2D 차원의 좌표를 별도로 3D 차원의 좌표로 바꾸어 줘야 할 필요성이 있다.
또한 clientX, clientY를 그대로 사용하지 못하는 이유 중 다른 것은 canvas의 크기 및 비율 적 문제이다. clientX, clientY는 각각 왼쪽에서 마우스가 얼마나 떨어져 있는지, 위 쪽에서 마우스가 얼마나 떨어져 있는 지를 pixel 값으로 반환 하는데 이 값은 모니터의 해상도 마다, 크기 마다 다르며 canvas의 크기가 항상 모니터를 꽉 차게 두지는 않기 때문에 clientX, clientY 값을 정규화 시켜 줄 필요가 있다.
이러한 작업을 가능케 해주는 것이 Three.js의 raycaster 함수이다.
간단한 Raycaster의 작동 방식
Raycaster 함수는 카메라를 기준으로 특정의 가상 광선을 생성한다. 이 광선은 화면상에서 마우스 위치에 해당하는 방향으로 쏘아 진다.
마우스 위치를 3D 공간으로 변환
마우스 위치 (clientX, clientY) 값을 -1, 1 사이의 정규화 된 값으로 변환 한다. (이 범위는 Three.js에서 사용하는 좌표계에 맞추기 위함)
이렇게 정규화 된 좌표 값을 Raycaster 함수의 인자 값으로 전달 한다.교차점 계산
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 함수 계산 공식 설명
Three.js에서는 좌, 우 -1, 1 그리고 상, 하 -1, 1의 범위를 가지는 좌표계 시스템을 가지고 있다.
mouse의 clientX, clientY 값은
모니터의 맨 왼쪽 기준에서 마우스가 얼마나 떨어져 있는지 혹은 모니터의 맨 위쪽 기준에서 마우스가 얼마나 떨어져 있는 건지를 나타내는 값이며 가장 맨 왼쪽, 맨 위쪽은 각 각 0의 값에서 시작 한다.
예:)
모니터의 가장 왼쪽:0,
모니터의 가장 오른쪽: window.innerWidth
모니터의 가장 위쪽: 0
모니터의 가장 아래쪽: window.innerHeight
기본적으로 이러한 client 좌표 값은 pixel 값으로 이루어져 있으며 모니터의 크기, 해상도에 따라 마우스가 움직이는 범위의 값이 달라지며 좌표의 값도 달라 진다.
위 3번의 이유로 인해 특정 범위, 0 ~ 1 사이에서 모니터의 종류에 상관 없이 좌표의 값이 나타날 수 있도록 정규화 작업이 필요 하다.
0에서 1 사이의 값으로 정규화 하기 위해 (마우스의 좌표 값 / 모니터 좌우, 상하의 최대 값) 이런식으로 계산해주면 현재 마우스의 좌표 값이 모니터 밖으로 나갈 수는 없으므로 1이상의 값을 가지지 못하며 같은 이유로 0이하의 값을 가지지 못한다.
이렇게 0 ~ 1 사이의 값으로 좌표 값을 구해주고 나면 해당 값을 Three.js 좌표계 시스템에 맞도록 계산 해주어야 하는데 이 값은 -1 ~ 1 사이의 값으로 표현 해야 한다.
6번의 조건을 충족 시켜주기 위해서 (좌표 값 / 최대 값)에서 나온 값에 x 2를 해주고 - 1를 해준다. 이는 x 좌표 값을 기준으로 한 것인데, 설명 하자면 0 - 1 사이에 값에서 중앙을 (0, 0) 값으로 해주기 위해서는 나온 값에 2를 곱해주고 - 1을 해주어야 좌표가 중앙으로 왔을 때 0이 되도록 기준을 맞출 수 있다.
여기서 y 값은 x 좌표 값과 동일하게 계산 하되 계산하여 나온 결과 값에는 음수 부호를 붙여 준다. 여기서 음수 부호를 붙여 주는 이유는 x 값은 왼쪽에서 오른쪽으로 값이 증가 하는 형태가 브라우저와 three.js가 동일하지만 y 값의 경우 브라우저는 아래로 갈 수록 값이 늘어나고 three .js 시스템에서는 아래로 갈 수록 값이 떨어지기 때문에 브라우저 증가 형태를 기준으로 계산한 값은 three.js에서는 값이 늘어나도 실제로는 줄어들어야 함으로 음수 부호를 붙여주는 것이다.
여기서 canvas element란 모든 3D 객체를 감싸는 역할을 한다.
원래 처음에 작업 했을때에는 마우스의 clientX, clientY 값을 가져오기 위해서 canvas element 내에 있는 onMouseMove 이벤트에서 값을 가져 왔다. 하지만 수정 후에는 마우스 좌표 값을 3D 객체 컴포넌트 내에서 window 객체에 있는 mouse move 이벤트를 등록 해서 좌표 값을 가져 와주었는데 이에 대한 이유는 아래와 같다.
만약 canvas 내에서만 작동하고 싶고, 딱히 컴포넌트로 빼고 싶지 않다고 하면 clientX, clientY 값은 window에서 나오는 값이나 onMouseMove에서 나오는 값이나 같기 때문에 canvas 이벤트를 사용해도 문제 없다
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,
);
}
});