본문 바로가기
Graphic/ThreeJS

22 Raycaster와 MouseEvent

by curious week 2025. 7. 29.

Three.js Raycaster 정리 및 보완 설명

1. Raycaster란?

Raycaster는 특정 위치에서 특정 방향으로 광선(ray) 을 쏘고, 그 광선과 어떤 3D 객체가 교차하는지 판단하는 도구입니다.

활용 예:

  • 플레이어 앞에 벽이 있는지 감지
  • 총알(광선)이 적을 맞췄는지 체크
  • 마우스로 클릭하거나 호버했을 때 어떤 객체가 있는지 탐지

2. Raycaster 생성 및 기본 설정

const raycaster = new THREE.Raycaster();

const rayOrigin = new THREE.Vector3(-3, 0, 0); // 광선의 시작점
const rayDirection = new THREE.Vector3(10, 0, 0); // 광선의 방향
rayDirection.normalize(); // 방향 벡터는 반드시 정규화해야 함 (길이 1)

raycaster.set(rayOrigin, rayDirection);

💡 normalize()를 호출하면 방향 벡터의 길이를 1로 맞춰줍니다. 크기는 중요하지 않고, 방향만 중요하기 때문입니다.


3. 교차 테스트

const intersects = raycaster.intersectObjects([object1, object2, object3]);
  • intersectObject(object) → 단일 객체와 교차 검사
  • intersectObjects([objects]) → 다수 객체와 교차 검사

객체의 모양에 따라(예를 들어, 도넛의 중앙을 통과하는 경우 2번) 하나의 객체와 여러 번 충돌할 수 있음.

반환값: intersects (배열)

각 항목에는 다음 정보가 포함됨:

  • distance: 원점에서 충돌 지점까지의 거리
  • point: 교차 지점의 위치 (THREE.Vector3)
  • object: 충돌한 객체
  • face, faceIndex, uv 등도 포함

※ 충돌 객체를 찾지 못하는 문제 발생 시:

/* Three.js는 렌더링 직전에 객체의 좌표(행렬이라고 함)를 업데이트합니다. 
레이 캐스팅을 즉시 실행하기 때문에 객체가 렌더링되지 않습니다.
레이 캐스팅 전에 수동으로 행렬을 업데이트하면 이 문제를 해결할 수 있습니다. */
object1.updateMatrixWorld();
object2.updateMatrixWorld();
object3.updateMatrixWorld();

4. 매 프레임마다 애니메이션 객체와 교차 검사

const tick = () => {
  const elapsedTime = clock.getElapsedTime();

  // 애니메이션: 구체들을 위아래로 이동시킴
  object1.position.y = Math.sin(elapsedTime * 0.3) * 1.5;
  object2.position.y = Math.sin(elapsedTime * 0.8) * 1.5;
  object3.position.y = Math.sin(elapsedTime * 1.4) * 1.5;

  // 교차 검사
  const rayOrigin = new THREE.Vector3(-3, 0, 0);
  const rayDirection = new THREE.Vector3(1, 0, 0).normalize();
  raycaster.set(rayOrigin, rayDirection);

  const intersects = raycaster.intersectObjects([object1, object2, object3]);

  // 기본 색상 초기화
  for (const obj of [object1, object2, object3]) {
    obj.material.color.set('#ff0000');
  }

  // 교차한 객체를 파란색으로 변경
  for (const intersect of intersects) {
    intersect.object.material.color.set('#0000ff');
  }
};

5. 마우스를 이용한 교차 검사 (setFromCamera)

setFromCamera(mouse, camera)는 "이 마우스 위치에서 시작해서 카메라 시점으로 광선을 쏴줘" 라고 명령하는 메서드

const mouse = new THREE.Vector2();

window.addEventListener('mousemove', (event) => {
  mouse.x = (event.clientX / sizes.width) * 2 - 1;
  mouse.y = -(event.clientY / sizes.height) * 2 + 1;
});

const tick = () => {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects([object1, object2, object3]);

  // 호버된 객체만 파란색 처리, 나머지는 빨간색 복원
  for (const obj of [object1, object2, object3]) {
    obj.material.color.set('#ff0000');
  }
  for (const intersect of intersects) {
    intersect.object.material.color.set('#0000ff');
  }
};

💡 setFromCamera(mouse, camera)를 쓰면 마우스 위치 기준으로 카메라에서 광선을 쏩니다.


6. 마우스 진입(mouseenter) / 이탈(mouseleave) 이벤트 흉내내기

let currentIntersect = null;

const tick = () => {
  raycaster.setFromCamera(mouse, camera);
  const intersects = raycaster.intersectObjects([object1, object2, object3]);

  if (intersects.length > 0) {
    if (!currentIntersect) console.log('mouse enter');
    currentIntersect = intersects[0];
  } else {
    if (currentIntersect) console.log('mouse leave');
    currentIntersect = null;
  }
};

7. 마우스 클릭 이벤트 핸들링

window.addEventListener('click', () => {
  if (currentIntersect) {
    switch (currentIntersect.object) {
      case object1:
        console.log('click on object 1');
        break;
      case object2:
        console.log('click on object 2');
        break;
      case object3:
        console.log('click on object 3');
        break;
    }
  }
});

8. GLTF 모델에 레이캐스팅 적용하기

모델 로딩

const gltfLoader = new GLTFLoader();
let model = null;

// 모델 로딩
gltfLoader.load('./models/Duck/glTF-Binary/Duck.glb', (gltf) => {
  model = gltf.scene;
  model.position.y = -1.2;
  scene.add(model);
});

교차 검사 및 상호작용

const tick = () => {
  if (model) {
    const modelIntersects = raycaster.intersectObject(model);
    if (modelIntersects.length) {
      model.scale.set(1.2, 1.2, 1.2); // 커서가 닿으면 확대
    } else {
      model.scale.set(1, 1, 1); // 닿지 않으면 원래 크기로
    }
  }
};

⚠️ 주의할 점

  • model은 Group이며 내부에 실제 Mesh들이 있음
  • 다행히도 Raycaster는 내부 자식까지 재귀적으로 검사하므로 model 전체에 대해 한 번만 검사해도 됨
  • intersectObject(model, true) 와 같이 두 번째 인자를 명시하면 재귀 여부를 직접 설정할 수 있음 (기본값: true)

정리 요약

Raycaster 생성 new THREE.Raycaster()
수동 설정 raycaster.set(origin, direction)
마우스 기준 설정 raycaster.setFromCamera(mouse, camera)
단일 객체 검사 raycaster.intersectObject(object)
다수 객체 검사 raycaster.intersectObjects([objs])
반환값 항상 배열 형태이며, 교차 정보 포함
자식 검사 기본적으로 true (재귀적 검사)

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import GUI from 'lil-gui';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

/**
 * Base
 */
// Debug
const gui = new GUI(); // 디버그 UI 생성 (현재 사용은 안함)

// Canvas
const canvas = document.querySelector('canvas.webgl'); // 렌더링할 <canvas> 요소

// Scene
const scene = new THREE.Scene(); // 장면(Scene) 생성

/**
 * Objects
 */
const object1 = new THREE.Mesh(
  new THREE.SphereGeometry(0.5, 16, 16), // 반지름 0.5, 세그먼트 16x16의 구체
  new THREE.MeshBasicMaterial({ color: '#ff0000' }), // 기본 머티리얼 (조명 영향을 받지 않음)
);
object1.position.x = -2;

const object2 = new THREE.Mesh(
  new THREE.SphereGeometry(0.5, 16, 16),
  new THREE.MeshBasicMaterial({ color: '#ff0000' }),
);

const object3 = new THREE.Mesh(
  new THREE.SphereGeometry(0.5, 16, 16),
  new THREE.MeshBasicMaterial({ color: '#ff0000' }),
);
object3.position.x = 2;

scene.add(object1, object2, object3); // 세 개의 구체를 장면에 추가

// object1.updateMatrixWorld();
// object2.updateMatrixWorld();
// object3.updateMatrixWorld();

/**
 * Raycaster
 */
const raycaster = new THREE.Raycaster(); // 광선 투사용 Raycaster 인스턴스 생성

// const rayOrigin = new THREE.Vector3(-3, 0, 0);
// const rayDirection = new THREE.Vector3(10, 0, 0); // 원한는 방향으로의 거리
// rayDirection.normalize();
// new THREE.TorusGeometry();
// raycaster.set(rayOrigin, rayDirection);

// const intersect = raycaster.intersectObject(object2);
// console.log(intersect);

// const intersects = raycaster.intersectObjects([object1, object2, object3]);
// console.log(intersects);

/**
 * Sizes
 */
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
};

window.addEventListener('resize', () => {
  // 창 크기 변경 시 사이즈 업데이트
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;

  // 카메라 비율 조정
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();

  // 렌더러 사이즈 및 해상도 조정
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 성능 최적화
});

/**
 * Mouse
 */
const mouse = new THREE.Vector2(); // 마우스 위치 (NDC 좌표: -1 ~ 1)

window.addEventListener('mousemove', (e) => {
  // 마우스 좌표를 NDC (Normalized Device Coordinates)로 변환
  mouse.x = (e.clientX / sizes.width) * 2 - 1;
  mouse.y = -(e.clientY / sizes.height) * 2 + 1;

  // tick() 안에서 raycaster.setFromCamera()를 통해 실제 캐스팅 수행

  /**
   * 콜백에서 레이를 캐스팅할 수도 있지만,
   * 일부 브라우저에서는 mousemove이벤트가 프레임 속도보다 더 빠르게 트리거될 수 있으므로 권장하지 않음.
   * => 그러므로 tick에서 프레임 속도로 이벤트 캐스팅
   */
});

window.addEventListener('click', () => {
  // 교차된 객체가 있을 때 클릭에 반응
  if (currentIntersect) {
    switch (currentIntersect.object) {
      case object1:
        console.log('ob1');
        break;
      case object2:
        console.log('ob2');
        break;
      case object3:
        console.log('ob3');
        break;
    }
  }
});

/**
 * Camera
 */
// 원근 카메라 생성: (시야각, 종횡비, 근거리 클리핑, 원거리 클리핑)
const camera = new THREE.PerspectiveCamera(
  75,
  sizes.width / sizes.height,
  0.1,
  100,
);
camera.position.z = 3; // 카메라 위치 (Z축으로 멀리 떨어뜨림)
scene.add(camera);

// 궤도 카메라 컨트롤 생성 (마우스로 회전 가능)
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true; // 관성 효과 (자연스러운 감속)

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
  canvas: canvas, // 앞서 선택한 <canvas> 요소에 렌더링
});
renderer.setSize(sizes.width, sizes.height); // 렌더러 크기 설정
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 고해상도 디바이스 대응

/**
 * Models
 */
const glTFLoader = new GLTFLoader(); // GLTF 모델 로더

let model = null; // 나중에 추가할 모델 참조용

glTFLoader.load('./models/Duck/glTF-Binary/Duck.glb', (glTF) => {
  model = glTF.scene; // 씬 객체 추출
  glTF.scene.position.y = -1.2; // 위치 조정
  scene.add(glTF.scene); // 장면에 모델 추가
});

/**
 * Lights
 */
// 주변광 (전체적으로 고르게 조명)
const ambientLight = new THREE.AmbientLight('#ffffff', 0.9);
scene.add(ambientLight);

// 방향광 (태양처럼 방향을 갖는 광원)
const directionalLight = new THREE.DirectionalLight('#ffffff', 2.1);
directionalLight.position.set(1, 2, 3);
scene.add(directionalLight);

/**
 * Animate
 */
const clock = new THREE.Clock(); // 경과 시간 측정용 시계

let currentIntersect = null; // 현재 광선과 교차 중인 객체

const tick = () => {
  const elapsedTime = clock.getElapsedTime(); // 현재까지 경과 시간

  // 각 오브젝트를 다른 속도로 상하 진동시키기
  object1.position.y = Math.sin(elapsedTime * 0.3) * 1.5;
  object2.position.y = Math.sin(elapsedTime * 0.8) * 1.5;
  object3.position.y = Math.sin(elapsedTime * 1.4) * 1.5;

  // Cast a ray
  //   const rayOrigin = new THREE.Vector3(-3, 0, 0);
  //   const rayDirection = new THREE.Vector3(1, 0, 0);
  //   rayDirection.normalize();

  //   raycaster.set(rayOrigin, rayDirection);

  //   const objectsToTest = [object1, object2, object3];
  //   const intersects = raycaster.intersectObjects(objectsToTest);

  //   console.log(intersects.length);

  //   for (const object of objectsToTest) {
  // 원래 색상으로 변경
  //     object.material.color.set('#ff0000');
  //   }

  //   for (const intersect of intersects) {
  // 색상 변경을 위해서 object의 Mesh에 접근해야한다.
  //     intersect.object.material.color.set('#0000ff');
  //   }

  // 마우스 위치 기반으로 레이캐스팅 수행
  raycaster.setFromCamera(mouse, camera);

  const objectsToTest = [object1, object2, object3];
  const intersects = raycaster.intersectObjects(objectsToTest); // 교차 판정

  // 색상 초기화
  for (const object of objectsToTest) {
    object.material.color.set('#ff0000');
  }

  // 교차된 오브젝트는 색상 변경
  for (const intersect of intersects) {
    intersect.object.material.color.set('#0000ff');
  }

  // 마우스 enter/leave 감지
  if (intersects.length) {
    if (currentIntersect === null) {
      console.log('mouse enter');
    }
    currentIntersect = intersects[0];
  } else {
    if (currentIntersect) {
      console.log('mouse leave');
    }
    currentIntersect = null;
  }

  // 모델과의 교차 검사
  if (model) {
    const modelIntersects = raycaster.intersectObject(model);
    if (modelIntersects.length > 0) {
      model.scale.set(1.2, 1.2, 1.2); // 마우스 오버 시 모델 확대
    } else {
      model.scale.set(1, 1, 1); // 평상시 크기
    }
  }

  controls.update(); // OrbitControls의 관성 효과 적용
  renderer.render(scene, camera); // 렌더링 수행
  window.requestAnimationFrame(tick); // 다음 프레임 예약
};

tick(); // 애니메이션 루프 시작

'Graphic > ThreeJS' 카테고리의 다른 글

25 Realistic render  (2) 2025.07.31
24 Environment Map  (3) 2025.07.31
21 모델 불러오기(Imported models)  (4) 2025.07.29
20 물리엔진(Physics)  (3) 2025.07.29
19 스크롤 기반 애니메이션  (8) 2025.07.28