BabylonJS 13일차 : 레이캐스트로 상호작용(문 열기/버튼) — ray, intersection / 특정 거리 + E키
게임이나 시뮬레이션에서 가장 흔한 상호작용은 “가까이 가서, 바라보고, 키를 눌러서” 무언가를 조작하는 패턴입니다. 예를 들어 문 열기/닫기, 버튼 누르기, 아이템 줍기, 레버 당기기 등이 모두 같은 구조를 공유합니다. Babylon.js에서는 ray(레이)와 intersection(교차 판정)을 이용해 이 패턴을 간단하면서도 확장 가능하게 만들 수 있습니다.
이번 글의 목표는 특정 거리 내 오브젝트를 레이캐스트로 찾고, E 키로 상호작용을 트리거하여 “문 열기/닫기” 산출물까지 완성하는 것입니다. 초보자 관점에서 이해하기 쉬운 비유부터, 실무에서 바로 쓰는 구조(인터랙션 매니저, 하이라이트, 디바운스, 인터페이스 설계)까지 단계적으로 정리합니다.
목차
- 상호작용 패턴의 본질: “시선 + 거리 + 입력”
- 핵심 개념: ray, intersection 쉽게 이해하기
- BabylonJS에서 레이캐스트 하는 3가지 방법
- 구현에 필요한 설정 요약 표
- 실습: 특정 거리 내 오브젝트 상호작용 키(E)
- 산출물: 문 열기/닫기(상태 기반)
- 사용감 개선: 하이라이트/안내문/오작동 방지
- 문제 해결 체크리스트
- 추가로 생각해볼 점(확장 설계)
- 블로그 최적화 정보
핵심 포인트: 상호작용 패턴의 본질
- 시선(Focus): 플레이어가 무엇을 “바라보는지”를 알아야 합니다. 보통 카메라 정면 방향으로 레이를 쏘아 판단합니다.
- 거리(Range): 너무 멀리 있는 오브젝트까지 작동하면 게임성이 깨집니다. 최대 상호작용 거리를 제한합니다.
- 입력(Input): 자동으로 열리기보다, 보통은
E같은 키로 “의도”를 확인합니다.
즉, 매 프레임(또는 일정 주기) “정면 레이캐스트 → 가장 가까운 히트 대상 → 거리 조건 만족 → E키 입력 시 실행” 흐름을 만들면, 문/버튼/아이템/레버 모두 같은 방식으로 확장할 수 있습니다.
상세 설명: ray, intersection을 쉬운 비유로 이해하기
ray(레이): 보이지 않는 “레이저 포인터”
레이는 한 점(원점)에서 어떤 방향으로 쭉 뻗는 선입니다. 3D 게임에서는 보통 카메라 위치에서 카메라가 바라보는 방향으로 레이를 쏩니다. 마치 손전등이나 레이저 포인터를 정면으로 비추는 느낌으로 생각하시면 됩니다.
intersection(교차): 레이가 물체를 “처음 만나는 지점”
레이가 어떤 메시(mesh)에 닿으면, Babylon.js는 교차 여부(맞았는지), 맞은 메시, 교차 지점, 거리 같은 정보를 제공합니다. 이 중 상호작용에서 특히 중요한 것은:
hit: 무언가 맞았는가pickedMesh: 무엇을 맞았는가distance: 원점부터 얼마나 가까운가
BabylonJS에서 레이캐스트 하는 3가지 방법
- 1)
scene.pick: 화면 좌표(마우스 포인터 등) 기반으로 픽킹을 수행합니다. UI 클릭형에 적합합니다. - 2)
scene.pickWithRay: 직접 만든 레이로 픽킹합니다. “카메라 정면 상호작용”에 가장 흔히 쓰입니다. - 3)
camera.getForwardRay: 카메라 정면 레이를 쉽게 만들고, 이를pickWithRay에 넣습니다.
이번 실습은 2) + 3) 조합으로 구현합니다. “카메라 정면”이라는 직관적인 규칙을 만들면, 초보자도 디버깅이 쉬워집니다.
구현에 필요한 설정 요약 표
| 항목 | 내용 | 권장 값/설명 | 초보자 실수 |
|---|---|---|---|
| 상호작용 거리 | maxInteractDistance |
2~4 정도(걷기 속도/씬 스케일에 맞춤) | 너무 크면 멀리서도 문이 열려 몰입이 깨짐 |
| 레이 생성 | camera.getForwardRay(length) |
length를 거리 제한과 맞춤 | length를 안 맞추면 pick은 되지만 거리 로직이 꼬일 수 있음 |
| 교차 판정 | scene.pickWithRay(ray, predicate) |
predicate로 인터랙션 대상만 필터링 | 모든 메시에 히트되어 UI/배경에 걸리는 오작동 |
| 입력 처리 | scene.onKeyboardObservable |
E키 다운 이벤트에서 실행 | 누르고 있는 동안 연속 실행(토글이 미친 듯이 반복) |
| 문 상태 | isOpen boolean |
열림/닫힘 상태 기반 토글 | 각도 누적/원점 틀어짐으로 문이 점점 이상해짐 |
실행 단계: 특정 거리 내 오브젝트 상호작용 키(E)
구현을 두 단계로 나눕니다.
- A 단계: “카메라 정면 레이캐스트로 현재 바라보는 대상”을 찾기
- B 단계: “E 키를 눌렀을 때, 대상이 있고 거리 조건이 맞으면 실행”
A 단계: 매 프레임 ‘현재 포커스 대상’ 갱신
상호작용 대상이 여러 개일 때, 매번 모든 메시를 검사하면 성능도 떨어지고 관리도 어렵습니다. 따라서 “인터랙션 가능한 메시 목록”만 따로 관리하고, 레이캐스트 predicate로 필터링하는 방식을 권장합니다.
B 단계: 키 입력(E)에서 트리거
입력은 “키 다운” 이벤트에서 한 번만 실행되도록 구성합니다. 키를 누르고 있는 동안 계속 토글되면 문이 깜빡이거나 이상한 상태가 되기 쉽습니다.
산출물: “문 열기/닫기” 완성 예제
아래 예제는 다음을 모두 포함합니다.
- 카메라 정면 레이캐스트로 대상 찾기(
ray,intersection) - 최대 거리 제한(
maxInteractDistance) - E 키로 상호작용
- 문 열기/닫기 토글(상태 기반)
- 바라보는 대상 하이라이트(간단 버전)
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Babylon.js Raycast Interaction (Door Toggle)</title>
<style>
html, body { width:100%; height:100%; margin:0; overflow:hidden; }
#renderCanvas { width:100%; height:100%; touch-action:none; display:block; }
.hint {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
padding: 10px 12px; border: 1px solid #ddd; border-radius: 10px;
background: rgba(255,255,255,0.9); font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font-size: 14px; color:#222; display:none; user-select:none;
}
</style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
<div id="hint" class="hint">E 키로 상호작용</div>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const createScene = () => {
const scene = new BABYLON.Scene(engine);
// 라이트
const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), scene);
light.intensity = 0.9;
// 바닥
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 40, height: 40 }, scene);
const groundMat = new BABYLON.StandardMaterial("gmat", scene);
groundMat.diffuseColor = new BABYLON.Color3(0.88, 0.88, 0.9);
ground.material = groundMat;
// 카메라(걷기 느낌)
const camera = new BABYLON.UniversalCamera("cam", new BABYLON.Vector3(0, 2, -10), scene);
camera.attachControl(canvas, true);
camera.speed = 0.35;
camera.angularSensibility = 4000;
camera.keysUp = [87, 38];
camera.keysDown = [83, 40];
camera.keysLeft = [65, 37];
camera.keysRight = [68, 39];
// 간단 장애물(벽)
const wall = BABYLON.MeshBuilder.CreateBox("wall", { width: 12, height: 3, depth: 0.5 }, scene);
wall.position = new BABYLON.Vector3(0, 1.5, 6);
// 문(door) - 회전으로 열기/닫기
// 문은 피벗이 중요합니다. CreateBox는 중심이 피벗이라서 그냥 돌리면 가운데를 기준으로 회전합니다.
// 따라서 힌지(경첩) 효과를 위해, 문 메시를 TransformNode(doorRoot)에 붙여서 위치/피벗을 구성합니다.
const doorRoot = new BABYLON.TransformNode("doorRoot", scene);
doorRoot.position = new BABYLON.Vector3(-3, 1, 6); // 문이 위치할 곳(경첩 위치)
const door = BABYLON.MeshBuilder.CreateBox("door", { width: 1.6, height: 2.2, depth: 0.12 }, scene);
door.parent = doorRoot;
door.position = new BABYLON.Vector3(0.8, 0.1, 0); // 경첩에서 문 폭의 절반만큼 이동
const doorMat = new BABYLON.StandardMaterial("doorMat", scene);
doorMat.diffuseColor = new BABYLON.Color3(0.55, 0.38, 0.25);
door.material = doorMat;
// 버튼(옵션) - 눌리면 색 변경
const button = BABYLON.MeshBuilder.CreateCylinder("button", { height: 0.2, diameter: 0.6 }, scene);
button.position = new BABYLON.Vector3(3, 1, 5.4);
button.rotation.x = Math.PI / 2;
const buttonMat = new BABYLON.StandardMaterial("btnMat", scene);
buttonMat.diffuseColor = new BABYLON.Color3(0.8, 0.1, 0.1);
button.material = buttonMat;
// ---------------------------
// 인터랙션 시스템(핵심)
// ---------------------------
const maxInteractDistance = 3.0;
const interactables = new Set([door, button]); // 상호작용 대상만 모아 관리
const hintEl = document.getElementById("hint");
// 하이라이트(간단): 현재 포커스 대상의 emissiveColor로 표시
let focusedMesh = null;
let prevEmissive = null;
const setFocus = (mesh) => {
// 이전 포커스 해제
if (focusedMesh && focusedMesh.material && prevEmissive) {
focusedMesh.material.emissiveColor = prevEmissive.clone();
}
focusedMesh = mesh;
prevEmissive = null;
if (focusedMesh && focusedMesh.material) {
prevEmissive = focusedMesh.material.emissiveColor ? focusedMesh.material.emissiveColor.clone() : new BABYLON.Color3(0,0,0);
focusedMesh.material.emissiveColor = new BABYLON.Color3(0.1, 0.3, 0.8);
}
};
const updateFocus = () => {
const ray = camera.getForwardRay(maxInteractDistance);
// predicate: interactables만 대상으로 pick
const hit = scene.pickWithRay(ray, (m) => interactables.has(m));
if (hit && hit.hit && hit.pickedMesh && hit.distance <= maxInteractDistance) {
if (hit.pickedMesh !== focusedMesh) setFocus(hit.pickedMesh);
hintEl.style.display = "block";
} else {
if (focusedMesh) setFocus(null);
hintEl.style.display = "none";
}
};
// 문 상태 + 애니메이션(부드러운 토글)
let doorIsOpen = false;
let doorAnimating = false;
const toggleDoor = () => {
if (doorAnimating) return; // 연타 방지
doorAnimating = true;
const startY = doorRoot.rotation.y;
const targetY = doorIsOpen ? 0 : -Math.PI / 2; // 닫힘(0) ↔ 열림(-90도)
const durationMs = 220;
const startTime = performance.now();
const animate = (t) => {
const k = Math.min(1, (t - startTime) / durationMs);
// easeOut
const eased = 1 - Math.pow(1 - k, 3);
doorRoot.rotation.y = startY + (targetY - startY) * eased;
if (k < 1) {
requestAnimationFrame(animate);
} else {
doorIsOpen = !doorIsOpen;
doorAnimating = false;
}
};
requestAnimationFrame(animate);
};
const pressButton = () => {
// 버튼은 눌리면 색 토글
const isRed = buttonMat.diffuseColor.r > 0.5;
buttonMat.diffuseColor = isRed ? new BABYLON.Color3(0.1, 0.7, 0.2) : new BABYLON.Color3(0.8, 0.1, 0.1);
};
// 키 입력: E 키 다운에서 실행(연속 토글 방지)
scene.onKeyboardObservable.add((kbInfo) => {
if (kbInfo.type !== BABYLON.KeyboardEventTypes.KEYDOWN) return;
const key = kbInfo.event.key.toLowerCase();
if (key !== "e") return;
if (!focusedMesh) return;
if (focusedMesh === door) toggleDoor();
if (focusedMesh === button) pressButton();
});
// 매 프레임 포커스 갱신
scene.onBeforeRenderObservable.add(() => {
updateFocus();
});
scene.clearColor = new BABYLON.Color4(0.95, 0.97, 1.0, 1.0);
return scene;
};
const scene = createScene();
engine.runRenderLoop(() => {
scene.render();
});
window.addEventListener("resize", () => engine.resize());
</script>
</body>
</html>
사용감 개선: 하이라이트/안내문/오작동 방지(실무 팁)
- 하이라이트는 “확인 신호”: 포커스된 대상이 무엇인지 눈으로 보이지 않으면, 사용자는 E키를 눌러도 왜 반응이 없는지 알기 어렵습니다.
간단히emissiveColor를 올리거나, 테두리 하이라이트 레이어(HighlightLayer)를 사용해도 됩니다. - 거리 제한은 레이 길이와 함께 관리:
maxInteractDistance를 만들었다면,camera.getForwardRay(maxInteractDistance)처럼 레이 길이와 한 세트로 묶는 것이 실수 방지에 좋습니다. - 연타/홀드 방지: 토글형 인터랙션(문, 스위치)은 키를 누르는 동안 반복 실행되면 안 됩니다.
따라서 “키 다운 1회”만 트리거하거나,doorAnimating같은 락을 둡니다. - 인터랙션 대상 분리: 배경 장식 메시까지 픽킹 대상에 포함하면 원치 않는 히트가 많아집니다.
Set이나 배열로 “인터랙션 가능한 메시만” 관리하고 predicate로 필터링하는 편이 안정적입니다. - 문 피벗(경첩) 구조: 문 열기/닫기에서 가장 흔한 실수는 “문이 가운데를 기준으로 빙글빙글 돈다”입니다.
문을TransformNode에 붙이고, 경첩 위치에 루트를 두는 방식이 가장 단순하면서도 확장성이 좋습니다.
문제 해결 체크리스트
| 증상 | 원인 후보 | 해결 방법 |
|---|---|---|
| E키를 눌러도 아무 반응 없음 | 포커스 대상이 null, predicate 필터가 잘못됨 | interactables에 메시가 들어있는지 확인하고, pickWithRay 결과의 hit/pickedMesh를 로그로 점검합니다. |
| 멀리서도 문이 열림 | maxInteractDistance가 너무 큼, 또는 거리 체크 누락 |
레이 길이와 거리 체크를 동일 값으로 맞추고, hit.distance 조건을 유지합니다. |
| 문이 계속 흔들리거나 각도가 누적됨 | 회전을 누적 계산하거나 키 입력이 반복됨 | “상태 기반(열림/닫힘)”으로 목표 각도를 고정하고, 애니메이션 중에는 입력을 막습니다. |
| 원치 않는 오브젝트가 자주 잡힘 | 픽킹 대상이 너무 넓음 | pickWithRay의 predicate로 인터랙션 대상만 허용하거나, 레이어/태그/메타데이터로 분리합니다. |
추가로 생각해볼 점: 확장 설계(게임/시뮬레이션 공통 패턴)
- 메타데이터 기반 인터랙션: 메시마다
mesh.metadata = { type: "door" }같은 태그를 달고, 공통 핸들러가 타입별 동작을 실행하도록 구성하면 확장성이 좋아집니다. - 인터랙션 인터페이스: 오브젝트가 “상호작용 가능”이라는 공통 규격(예:
interact(),getHint())을 갖도록 만들면, 문/버튼/레버/콘솔을 같은 시스템으로 묶을 수 있습니다. - 시야각/센터 스냅: 레이만으로는 “살짝 비껴봐도 잡히는” 느낌이 날 수 있습니다.
UI에서 크로스헤어를 보여주거나, 화면 중앙 근처만 허용하는 추가 조건을 넣으면 체감이 좋아집니다. - 장거리 조작(레이저 툴): 반대로 ‘정비 시뮬레이터’처럼 원거리 조작이 필요하면 거리 제한을 키우되, 대상별 허용 거리(문 3m, 패널 1.5m)를 다르게 두는 방식이 흔합니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글