개요
Raycaster는 3D 공간에 보이지 않는 광선(Ray)을 쏘아서 그 광선과 부딪히는(교차하는) 물체가 있는지 감지하는 도구입니다. 마치 레이저 포인트를 쏴서 벽에 점이 생기는 것을 확인하는 것과 같습니다.
이를 통해 다음과 같은 다양한 상호작용을 구현할 수 있습니다.
- 충돌 감지: 플레이어 캐릭터 앞에 벽이 있는지 확인할 수 있습니다.
- 정확한 타겟팅: 총을 쏘았을 때 어떤 물체에 맞았는지 판별할 수 있습니다.
- 마우스 인터렉션: 사용자가 마우스로 클릭하거나 카리키는 3D 객체를 알아낼 수 있습니다.
- 상태 알림: 우주선이 행성 방향으로 향하고 있을 때 경고 메시지를 띄울 수 있습니다.
해당 기능들은 게임에 경우에는 마우스로 다양한 이벤트를 사용하고 있습니다. Mouse Events와 함께 만들어보겠습니다.
Raycaster 기본 사용법
Raycaster를 사용하려면 먼저 '광선'을 정의해야 합니다. 광선은 '어디서(위치)' 시작해서 '어느 방향으로' 쏠 것인지에 대한 정보를 가집니다.
- Raycaster 생성: new THREE.Raycaster()로 인스턴스를 만듭니다.
- 광선의 위치 및 방향 설정:
- rayOrigin: 광선이 시작되는 위치(THREE.Vector3).
- rayDirection: 광선이 나아갈 방향(THREE.Vector3).
- normalize(): 방향 벡터는 순수한 '방향'정보만 필요하므로, normalize()를 호출하여 벡터의 크기를 1로 만들어줍니다.(벡터의 길이가 길다고 광선이 더 멀리 나가는 것은 아닙니다.)
- Raycaster에 광선 정보 적용: raycaster.set(rayOrigin, rayDirection) 메서드로 위에서 정의한 위치와 방향을 적용합니다.
/**
* Raycaster 생성 및 설정
*/
const raycaster = new THREE.Raycaster();
// 광선 시작 위치: x축으로 -3인 지점
const rayOrigin = new THREE.Vector3(-3, 0, 0);
// 광선 방향: 양의 x축 방향
const rayDirection = new THREE.Vector3(1, 0, 0);
rayDirection.normalize(); // 방향 벡터 정규화
// Raycaster에 광선 정보 설정
raycaster.set(rayOrigin, rayDirection);
충돌 감지 및 반환 정보
설정된 광선과 충돌하는지 검사할 객체들을 지정하여 충돌 테스트를 실행할 수 있습니다.
- intersectObject(object): 하나의 객체와의 충돌을 검사합니다.
- intersectObjects(objects): 배열에 담긴 여러 개의 객체와의 충돌을 검사합니다.
이 메서드들은 광선과 충돌한 객체들의 정보를 배열(Array) 형태로 반환합니다. 광선과 가까운 순서대로 정렬되어 있습니다.
각 물체에 대한 정보를 담고 있는 배열을 반환하고, 충돌 정보 객체에 담긴 주요 속성은 다음과 같습니다.
- distance: 광선의 시작점(rayOrigin)에서 충돌 지점(point)까지의 거리.
- point: 3D 공간에서 광선과 객체가 충돌한 정확한 위치 좌표(Vector3).
- object: 광선과 충돌한 객체 그 자체.
- face: 충돌이 일어난 면(face)에 대한 정보 (면을 구성하는 정점 a, b, c의 인덱스, 법선 벡터 등).
- faceIndex: 충돌한 면이 지오메트리(geometry)에서 몇 번째 면인지 나타내는 인덱스
- uv: 충돌 지점의 UV 좌표(텍스처 매핑에 사용되는 2D 좌표).
애니메이션과 실시간 충돌 감지
Raycaster는 매 프리엠마다 충돌을 검사할 수 있지만, Scene에 객체가 많거나 지오메트리가 복잡할수록 연산 부담이 커져 성능에 영향을 줄 수 있습니다. (해당 부분이 상당히 무겁다)
아래 예제는 tick 함수(매 프레임 실행되는 함수)안에서 실시간으로 움직이는 객체들과의 충돌을 감지하는 코드입니다.
- 매 프레임마다 sin 함수를 이용해 3개의 객체(object1, object2, object3)를 위아래로 움직입니다.
- 고정된 위치에서 x축 방향으로 광선을 쏩니다.
- intersectObjects로 움직이는 객체들과의 충돌을 검사합니다.
- (매우 중요) 매 프레임 시작 시, 모든 객체의 색을 일단 빨간색으로 초기화합니다. 만약 이과정이 없으면, 한번 파란색이 된 객체는 충돌이 끝나도 계속 파란색으로 남아있게 됩니다.
- 충돌이 감지된 객체(intersects 배열에 포함된 객체)만 색을 파란색으로 변경합니다.
// 애니메이션 루프 (tick 함수 내부)
const elapsedTime = clock.getElapsedTime();
// 1. 객체들 움직이기
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;
// 2. 광선 생성 및 설정
const rayOrigin = new THREE.Vector3(-3, 0, 0);
const rayDirection = new THREE.Vector3(1, 0, 0);
rayDirection.normalize();
raycaster.set(rayOrigin, rayDirection);
// 3. 충돌 감지할 객체 배열
const objectsToTest = [object1, object2, object3];
const intersects = raycaster.intersectObjects(objectsToTest);
// 4. 모든 객체 색상 초기화 (빨간색)
for (const object of objectsToTest) {
object.material.color.set('#ff0000');
}
// 5. 충돌된 객체만 색상 변경 (파란색)
for (const intersect of intersects) {
intersect.object.material.color.set('#0000ff');
}
마우스와 상호작용 (호버링 및 클릭)
마우스 좌표 변환
웹 브라우저의 마우스 좌표(픽셀 단위, 좌측 상단이 0,0)는 Three.js 3D 공간 좌표계와 다릅니다. 따라서 Raycaster가 사용할 수 있도록 좌표를 변환해야 합니다. Three.js는 화면의 중앙을 (0, 0), 가로 범위를 -1에서 +1, 세로 범위를 -1에서 +1로 하는 정규화된 장치 좌표(Normalized Device Coordinates)를 사용합니다.
/**
* Mouse 좌표를 저장할 Vector2
*/
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;
});
- mouse.x: clientX를 너비로 나누면 0~1 범위가 되고, * 2 - 1을 하여 -1 ~ +1 범위를 만듭니다.
- mouse.y: clientY는 위쪽이 0이지만 Three.js는 아래쪽이 -1이므로, 변환 값에 -를 붙여 방향을 뒤집어줍니다.
카메라에서 Ray 쏘기
변환된 마우스 좌표와 카메라를 이용하면, 카메라 시점에서 마우스 커서 방향으로 광선을 쏘는 것이 가능합니다. 기존에 있던 ray 구현은 주석처리 해줍니다.
// 기존 ray 주석 처리
// const rayOrigin = new THREE.Vector3(-3, 0, 0);
// const rayDirection = new THREE.Vector3(1, 0, 0);
// rayDirection.normalize();
// raycaster.set(rayOrigin, rayDirection);
// tick 함수 내부에서 실행
raycaster.setFromCamera(mouse, camera);
마우스 호버 (Enter / Leave) 감지
마우스가 객체 위에 올라갔을 때('enter')와 벗어났을 때('leave')를 구분하려면, 현재 마우스가 어떤 객체 위에 있는지 상태를 저장할 변수가 필요합니다. currentIntersect 변수를 사용해 이 로직을 구현할 수 있습니다.
let currentIntersect = null; // 현재 마우스가 올라간 객체를 저장할 변수
// tick 함수 내부
const intersects = raycaster.intersectObjects(objectsToTest);
if (intersects.length) { // 광선에 무언가 감지되었다면
if (!currentIntersect) {
console.log('Mouse enter'); // 이전 프레임엔 없었는데, 이번 프레임에 생김 -> enter
}
currentIntersect = intersects[0]; // 가장 가까운 객체를 현재 객체로 저장
} else { // 광선에 아무것도 감지되지 않았다면
if (currentIntersect) {
console.log('Mouse leave'); // 이전 프레임엔 있었는데, 이번 프레임에 사라짐 -> leave
}
currentIntersect = null; // 현재 가리키는 객체가 없으므로 null로 초기화
}
마우스 클릭 이벤트 처리
위에서 구현한 currentIntersect를 활용하면, 마우스를 클릭했을 때 어떤 객체를 클릭했는지 쉽게 알 수 있습니다.
window.addEventListener('click', () => {
if (currentIntersect) { // 마우스가 어떤 객체 위에 있을 때 클릭했다면
switch (currentIntersect.object) {
case object1:
console.log('Object 1 클릭!');
break;
case object2:
console.log('Object 2 클릭!');
break;
case object3:
console.log('Object 3 클릭!');
break;
}
}
});
currentIntersect.object는 위에서 설명한 충돌 객체이며, 광선과 가장 먼저 충돌한 3D 객체 그 자체(THREE.Mesh 등)를 가리킵니다.
glTF 모델과 상호작용
Raycaster는 직접 만든 Mesh 뿐만 아니라, 외부에서 불러운 3D 모델과도 동일하게 상호작용할 수 있습니다.
모델 로딩 및 비동기 처리
GLTFLoader를 사용해 모델을 불러옵니다. 모델 로딩은 비동기적으로(시간이 걸리는 작업) 이루어집니다. tick 함수는 로딩을 기다려주지 않고 바로 실행되기 때문에, 모델이 로드되기 전에 tick 함수 안에서 모델을 사용하려고 하면 에러가 발생합니다.(model 변수가 null인 상태)
이를 해결하기 위해 tick 함수 안에 if (model) 과 같은 조건문을 넣어, 모델 로딩이 완료된 후에만 관련 코드가 실행되도록 만들어야 합니다.
let model = null; // 처음에는 모델이 없으므로 null로 초기화
const gltfLoader = new GLTFLoader();
gltfLoader.load(
'/models/Duck/glTF-Binary/Duck.glb',
(gltf) => {
model = gltf.scene; // 로딩이 완료되면 model 변수에 씬(모델 그룹)을 할당
scene.add(model);
}
);
// tick 함수 내부
if (model) { // model이 null이 아닐 때, 즉 로딩이 완료되었을 때만 실행
// ... 모델과 상호작용하는 코드 ...
}
- 조명: GLTF 모델이 MeshStandardMaterial 등을 사용하는 경우, 빛이 없으면 검게 보입니다. AmbientLight나 DirectionalLight 같은 조명을 Scene에 추가해야 합니다.
그룹(Group) 객체와 충돌 감지
불러온 모델(gltf.scene)은 여러 개의 Mesh로 이루어진 Group 객체일 경우가 많습니다. raycaster.intersectObject(model)를 호출하면 Raycaster는 해당 그룹에 속한 모든 자식 메쉬들을 재귀적으로 탐색하여 충돌을 검사합니다.
따라서 intersectObject를 사용했더라도, 결과는 여러 개의 충돌 정보를 담은 배열로 반환될 수 있습니다. 그 이유는 다음과 같습니다.
- 여러 자식 메시와 교차: 모델이 여러 파츠(자식 메시)로 구성된 경우, 하나의 광선이 여러 파츠를 동시에 통과할 수 있습니다.
- 하나의 메시와 여러 번 교차: 오목하거나 복잡한 형태의 단일 메시인 경우, 광선이 해당 메시를 뚫고 들어갔다가 다시 나올때 등 여러 번 교차할 수 있습니다.
모델 호버 효과 구현
위의 원리를 종합하여, 마우스를 오리 모델 위에 올렸을 때 크기를 키우는 호버 효과를 구현할 수 있습니다.
// tick 함수 내부
if (model) {
const modelIntersect = raycaster.intersectObject(model); // 그룹 전체를 대상으로 검사
if (modelIntersect.length) { // 충돌한 자식 메시가 하나라도 있다면
model.scale.set(1.2, 1.2, 1.2); // 모델의 크기를 키움
} else {
model.scale.set(1, 1, 1); // 충돌하지 않았다면 원래 크기로 복원
}
}
결론
Raycaster는 Three.js에서 3D 객체와 사용자의 상호작용을 구현하는 데 필수적인 핵심 기능입니다.
Raycaster를 효과적으로 사용하기 위해서는 다음 사항들을 이해하는 것이 중요합니다.
- 정확한 광선 설정: 상호작용의 목적에 맞게 광선의 시작점(Origin)과 방향(Direction)을 정확히 설정해야 합니다. 특히 마우스와의 상호작용에서는 픽셀 좌표를 정규화된 장치 좌표로 변환하고 setFromCamera를 사용하는 것이 핵심입니다.
- 상태 관리: '마우스 올림(hover)'과 '벗어남(leave)' 같은 이벤트를 구현하기 위해서는 currentIntersect와 같은 변수를 사용하여 이전 프레임의 상태를 추적하는 로직이 반드시 필요합니다.
- 비동기 처리 및 성능: 모델 로딩과 같은 비동기 작업 시에는 tick 함수에서 객체가 null이 아닌지 확인하는 방어 코드가 중요합니다. 도한, Raycasting은 매 프레임마다 수많은 계산을 요구할 수 있으므로, 복잡한 Scene에서는 성능을 고려하여 꼭 필요한 객체들만 검사 대상에 포함시키는 최적화가 필요합니다
'Three.js > Advanced techniques' 카테고리의 다른 글
[threejs-journey 3-2] Imported models (0) | 2025.08.20 |
---|---|
[threejs-journey 3-1] Physics (0) | 2025.08.19 |