BabylonJS 13일차 : 레이캐스트로 상호작용(문 열기/버튼) — ray, intersection / 특정 거리 + E키



BabylonJS 13일차 : 레이캐스트로 상호작용(문 열기/버튼) — ray, intersection / 특정 거리 + E키

게임이나 시뮬레이션에서 가장 흔한 상호작용은 “가까이 가서, 바라보고, 키를 눌러서” 무언가를 조작하는 패턴입니다. 예를 들어 문 열기/닫기, 버튼 누르기, 아이템 줍기, 레버 당기기 등이 모두 같은 구조를 공유합니다. Babylon.js에서는 ray(레이)와 intersection(교차 판정)을 이용해 이 패턴을 간단하면서도 확장 가능하게 만들 수 있습니다.

이번 글의 목표는 특정 거리 내 오브젝트를 레이캐스트로 찾고, 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)를 다르게 두는 방식이 흔합니다.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

Reactions

댓글 쓰기

0 댓글