BabylonJS 24회차 : 물리엔진 도입(간단)



BabylonJS 24회차 : 물리엔진 도입(간단)

목표: 떨어지고 튀는 물리 상호작용을 구현합니다.
핵심 개념: physics plugin, rigid body
실습: 공 떨어뜨리기 + 벽 충돌
산출물: “미니 물리 샌드박스”


요약

물리를 도입하면 “중력, 충돌, 튕김, 미끄러짐” 같은 기본 상호작용을 직접 수학으로 구현하지 않아도 됩니다. 다만 물리는 아무 엔진이나 자동으로 붙는 것이 아니라, BabylonJS가 제공하는 physics plugin(어댑터)과, 실제 계산을 수행하는 물리 라이브러리(Cannon/Ammo/Oimo/Havok 등)를 연결하는 구조를 이해해야 안정적으로 동작합니다. 이번 회차에서는 가장 단순한 형태로 물리를 붙이고, 공을 떨어뜨린 뒤 벽에 충돌시키는 미니 샌드박스를 만들어 “물리 도입의 최소 성공 경험”을 확보합니다.


목차


1) 핵심 포인트

  • 물리는 “BabylonJS만으로 끝나지 않습니다”: BabylonJS는 플러그인으로 연결하고, 실제 계산은 외부 물리 라이브러리가 합니다.
  • Rigid Body(물리 바디) = 질량/속도/충돌 반응을 가진 객체라고 이해하시면 됩니다. (BabylonJS에서는 보통 physics impostor가 이 역할을 합니다.)
  • 정적(Static) vs 동적(Dynamic)을 먼저 나누세요: 바닥/벽은 mass 0(정적), 공은 mass 1(동적)처럼 구분하면 안정성이 좋아집니다.
  • 튜닝은 restitution(반발)과 friction(마찰)부터: “튀는 느낌/미끄러짐”이 바로 달라집니다.
  • 스케일(단위)와 중력은 같이 생각: 씬 단위가 너무 크거나 작으면 물리가 이상하게 보일 수 있습니다.


2) BabylonJS 물리 구조: Plugin vs 물리 라이브러리

초보자 단계에서 가장 중요한 이해는 “BabylonJS는 물리를 직접 계산하지 않고, 연결 역할을 한다”는 점입니다.

  • Physics Plugin: BabylonJS가 물리엔진과 통신하기 위한 어댑터입니다. 예: CannonJSPlugin, AmmoJSPlugin
  • 물리 라이브러리: 실제 충돌/중력/반발/적분 계산을 수행합니다. 예: Cannon.js, Ammo.js, Oimo.js 등

따라서 “플러그인을 켰는데 아무 일도 안 일어나는” 경우는 대부분 물리 라이브러리가 로딩되지 않았거나 전역 객체가 준비되지 않은 상태인 경우입니다. 이번 실습은 CDNs로 Cannon.js를 로딩하고, BabylonJS에서 scene.enablePhysics()로 연결하는 흐름을 사용합니다.


3) 용어 정리: rigid body, collider(충돌체), restitution(반발)

용어 의미 BabylonJS에서의 위치 초보자 팁
Rigid Body 물리 계산 대상(질량/속도/힘) 대개 PhysicsImpostor로 부여 움직이는 물체는 mass>0로 시작
Collider(충돌체) 충돌 판정용 단순 형상 Sphere/Box 등 impostor 타입 시각 메시보다 단순할수록 안정/성능 유리
restitution(반발) 튕김 정도(0~1) impostor 옵션 공/바닥 튕김을 먼저 이 값으로 조절
friction(마찰) 미끄러짐 정도 impostor 옵션 바닥 마찰이 낮으면 공이 오래 굴러갑니다
mass(질량) 동적/정적 구분 impostor 옵션 벽/바닥은 0, 공은 1부터 시작


4) 어떤 물리엔진을 선택할까?

프로젝트 성격에 따라 선택이 달라집니다. 이번 회차는 “간단 도입”이 목적이므로, 학습 장벽이 낮은 방향으로 정리합니다.

엔진/플러그인 특징 초보자 관점 추천 상황
Cannon.js
(CannonJSPlugin)
가벼운 편, 웹 데모에 자주 사용 CDN 포함이 쉬워 입문에 적합 간단한 상호작용/샌드박스
Ammo.js
(AmmoJSPlugin)
강력하지만 초기 설정/로딩이 까다로운 편 학습 비용이 올라갑니다 더 복잡한 물리, 정밀한 시뮬레이션
Oimo.js
(OimoJSPlugin)
경량, 기능은 비교적 단순 가볍게 붙이기 좋음 간단한 데모/프로토타입

이번 글의 실습 코드는 Cannon.js + CannonJSPlugin을 사용합니다. 이유는 “외부 라이브러리 포함 방식이 단순하고, 공 낙하/벽 충돌 같은 데모에 충분”하기 때문입니다.


5) 실습: 공 낙하 + 벽 충돌 “미니 물리 샌드박스”

실습 목표는 아래 2가지입니다.

  • 공이 중력으로 떨어져 바닥에서 튕깁니다.
  • 공이 벽에 부딪혀 반응(튕김/속도 변화)을 보입니다.

5-1) 완성 코드(그대로 실행 가능)

아래 코드는 index.html로 저장해서 실행하면 됩니다. 브라우저 보안 정책 때문에 로컬 파일 더블클릭보다는 정적 서버(예: VSCode Live Server) 실행을 권장합니다. 화면 왼쪽 위 UI에서 공을 계속 생성하고, 초기화할 수 있습니다.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>BabylonJS Mini Physics Sandbox (Ball Drop + Wall Collision)</title>
  <style>
    html, body { width:100%; height:100%; margin:0; overflow:hidden; font-family:system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
    #renderCanvas { width:100%; height:100%; display:block; touch-action:none; }

    #hud {
      position: fixed; left: 12px; top: 12px;
      width: min(460px, 94vw);
      padding: 12px 12px 10px;
      border-radius: 14px;
      background: rgba(0,0,0,0.55);
      border: 1px solid rgba(255,255,255,0.18);
      color: rgba(255,255,255,0.92);
      z-index: 10;
    }
    #hudTitle { margin:0 0 8px 0; font-size:14px; font-weight:700; }
    #stats { font-size:12px; line-height:1.55; }
    .row { display:flex; justify-content:space-between; gap:10px; }
    .k { color: rgba(255,255,255,0.75); }
    .v { font-variant-numeric: tabular-nums; }
    #btns { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
    button {
      appearance:none; border:1px solid rgba(255,255,255,0.22);
      background: rgba(255,255,255,0.10);
      color: rgba(255,255,255,0.92);
      padding: 7px 10px; border-radius: 10px;
      font-size:12px; cursor:pointer;
    }
    button:hover { background: rgba(255,255,255,0.16); }
    #hint { margin-top:8px; color: rgba(255,255,255,0.72); font-size:12px; line-height:1.45; }
    #hint code { background: rgba(255,255,255,0.12); padding: 1px 6px; border-radius: 6px; }
  </style>

  <script src="https://cdn.babylonjs.com/babylon.js"></script>

  <!-- Cannon.js: 전역 CANNON 객체를 제공해야 CannonJSPlugin이 동작합니다. -->
  <script src="https://cdn.jsdelivr.net/npm/cannon@0.6.2/build/cannon.min.js"></script>
</head>

<body>
  <div id="hud">
    <p id="hudTitle">미니 물리 샌드박스 (공 낙하 + 벽 충돌)</p>

    <div id="stats">
      <div class="row"><span class="k">FPS</span><span class="v" id="fps">-</span></div>
      <div class="row"><span class="k">Balls</span><span class="v" id="balls">0</span></div>
      <div class="row"><span class="k">Gravity</span><span class="v" id="grav">-9.81</span></div>
      <div class="row"><span class="k">Restitution(공/벽)</span><span class="v" id="rest">0.85 / 0.35</span></div>
    </div>

    <div id="btns">
      <button id="spawn">공 1개 생성</button>
      <button id="spawn10">공 10개 생성</button>
      <button id="reset">초기화</button>
    </div>

    <div id="hint">
      <div>팁: <code>Space</code>로 공 생성, <code>R</code>로 초기화</div>
      <div>관찰 포인트: 바닥 튕김(반발), 벽 충돌 후 속도 변화</div>
    </div>
  </div>

  <canvas id="renderCanvas"></canvas>

  <script>
    const canvas = document.getElementById("renderCanvas");
    const engine = new BABYLON.Engine(canvas, true);

    const ui = {
      fps: document.getElementById("fps"),
      balls: document.getElementById("balls")
    };

    let scene, camera;
    let balls = [];

    const SETTINGS = {
      gravity: new BABYLON.Vector3(0, -9.81, 0),
      ball: { mass: 1, restitution: 0.85, friction: 0.35 },
      wall: { mass: 0, restitution: 0.35, friction: 0.7 },
      ground: { mass: 0, restitution: 0.25, friction: 0.9 }
    };

    function createScene() {
      const s = new BABYLON.Scene(engine);
      s.clearColor = new BABYLON.Color4(0.06, 0.07, 0.09, 1.0);

      camera = new BABYLON.ArcRotateCamera(
        "camera", Math.PI / 2, Math.PI / 2.55, 18,
        new BABYLON.Vector3(0, 2.2, 0), s
      );
      camera.attachControl(canvas, true);
      camera.wheelDeltaPercentage = 0.01;

      const light = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), s);
      light.intensity = 1.0;

      // 물리 활성화(Plugin 연결)
      // Cannon.js가 로딩되어 전역 CANNON이 있어야 정상 동작합니다.
      s.enablePhysics(SETTINGS.gravity, new BABYLON.CannonJSPlugin());

      // 바닥(정적)
      const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 30, height: 30 }, s);
      ground.position.y = 0;
      const gmat = new BABYLON.StandardMaterial("gmat", s);
      gmat.diffuseColor = new BABYLON.Color3(0.15, 0.16, 0.18);
      gmat.alpha = 0.85;
      ground.material = gmat;

      ground.physicsImpostor = new BABYLON.PhysicsImpostor(
        ground,
        BABYLON.PhysicsImpostor.BoxImpostor,
        SETTINGS.ground,
        s
      );

      // 벽(정적) - 공이 부딪히는 구조를 만들기 위해 4면 + 코너를 간단히 구성
      const wallMat = new BABYLON.StandardMaterial("wallMat", s);
      wallMat.diffuseColor = new BABYLON.Color3(0.28, 0.30, 0.34);

      function createWall(name, w, h, d, x, y, z) {
        const wall = BABYLON.MeshBuilder.CreateBox(name, { width: w, height: h, depth: d }, s);
        wall.position.set(x, y, z);
        wall.material = wallMat;

        wall.physicsImpostor = new BABYLON.PhysicsImpostor(
          wall,
          BABYLON.PhysicsImpostor.BoxImpostor,
          SETTINGS.wall,
          s
        );
        return wall;
      }

      const wallH = 4.0;
      const wallT = 0.6;
      const half = 12.0;

      // 앞/뒤
      createWall("wallFront", 24, wallH, wallT, 0, wallH / 2, half);
      createWall("wallBack",  24, wallH, wallT, 0, wallH / 2, -half);

      // 좌/우
      createWall("wallLeft",  wallT, wallH, 24, -half, wallH / 2, 0);
      createWall("wallRight", wallT, wallH, 24,  half, wallH / 2, 0);

      // 중앙 표식(시각용)
      const marker = BABYLON.MeshBuilder.CreateTorus("marker", { diameter: 1.6, thickness: 0.1, tessellation: 48 }, s);
      marker.position.set(0, 0.12, 0);
      const mmat = new BABYLON.StandardMaterial("mmat", s);
      mmat.emissiveColor = new BABYLON.Color3(0.25, 0.35, 0.55);
      marker.material = mmat;
      marker.isPickable = false;

      return s;
    }

    function spawnBall() {
      const s = scene;

      const radius = 0.35;
      const ball = BABYLON.MeshBuilder.CreateSphere("ball" + balls.length, { diameter: radius * 2, segments: 24 }, s);

      // 위에서 떨어지게 배치(약간 랜덤)
      ball.position.x = (Math.random() - 0.5) * 4;
      ball.position.y = 7 + Math.random() * 2;
      ball.position.z = (Math.random() - 0.5) * 4;

      const bmat = new BABYLON.StandardMaterial("bmat" + balls.length, s);
      bmat.diffuseColor = new BABYLON.Color3(0.75, 0.78, 0.90);
      ball.material = bmat;

      // Rigid Body(PhysicsImpostor) 부여
      ball.physicsImpostor = new BABYLON.PhysicsImpostor(
        ball,
        BABYLON.PhysicsImpostor.SphereImpostor,
        SETTINGS.ball,
        s
      );

      // 벽 충돌이 눈에 띄도록: 앞으로 약간의 초기 임펄스를 줍니다.
      // impulse = 힘(순간) / contactPoint = 적용 지점
      const impulse = new BABYLON.Vector3(0, 0, 6.5);
      const contactPoint = ball.getAbsolutePosition();
      ball.physicsImpostor.applyImpulse(impulse, contactPoint);

      balls.push(ball);
      ui.balls.textContent = String(balls.length);
    }

    function spawnMany(n) {
      for (let i = 0; i < n; i++) spawnBall();
    }

    function resetSandbox() {
      balls.forEach(b => b.dispose());
      balls = [];
      ui.balls.textContent = "0";
    }

    // UI
    document.getElementById("spawn").addEventListener("click", () => spawnBall());
    document.getElementById("spawn10").addEventListener("click", () => spawnMany(10));
    document.getElementById("reset").addEventListener("click", () => resetSandbox());

    // 키보드 단축키
    window.addEventListener("keydown", (e) => {
      const k = e.key.toLowerCase();
      if (k === " " || k === "space") spawnBall();
      if (k === "r") resetSandbox();
      if (k === "escape" && balls.length > 0) resetSandbox();
    });

    // 시작
    scene = createScene();
    spawnMany(3);

    engine.runRenderLoop(() => {
      scene.render();
      ui.fps.textContent = engine.getFps().toFixed(1);
    });

    window.addEventListener("resize", () => engine.resize());
  </script>
</body>
</html>

5-2) 코드에서 꼭 봐야 하는 지점

  • 물리 활성화: scene.enablePhysics(gravity, new BABYLON.CannonJSPlugin())로 “중력 + 플러그인”을 연결합니다.
  • 정적 물체 설정: 바닥/벽은 mass: 0으로 둡니다. (움직이지 않는 환경)
  • 동적 물체 설정: 공은 mass: 1로 둡니다. (중력/충돌 반응)
  • 튕김 튜닝: 공의 restitution을 올리면 더 통통 튀고, 내리면 둔하게 됩니다.
  • 벽 충돌 연출: 공 생성 직후 applyImpulse로 초기 힘을 줘서 벽 충돌이 빠르게 관찰되도록 했습니다.


6) 실행 단계 체크리스트

  • 정적 서버로 실행합니다(로컬 파일 더블클릭보다 안정적입니다).
  • 공이 떨어지지 않으면 먼저 콘솔에서 오류를 확인합니다(물리 라이브러리 로딩 실패 가능성이 큽니다).
  • spawn 10을 여러 번 눌러도 물리가 유지되는지 관찰합니다(과도한 개수는 성능에 영향을 줄 수 있습니다).
  • restitution을 0.2~0.95 범위에서 바꿔 “튕김”이 어떻게 달라지는지 직접 확인합니다.
  • 벽/바닥의 friction을 조절해 “미끄러짐/굴림”이 어떻게 달라지는지 확인합니다.


7) 자주 막히는 지점(트러블슈팅)

증상 가능 원인 해결 방법
공이 떨어지지 않고 공중에 멈춤 물리 라이브러리(CANNON)가 로딩되지 않음 Cannon.js 스크립트 URL 확인, 네트워크 탭에서 로딩 성공 여부 확인
공이 바닥을 뚫고 내려감 충돌체(impostor) 설정 문제 또는 스케일/단위가 비정상 바닥 impostor 타입/사이즈 확인, 씬 스케일을 과도하게 키우지 않기
충돌이 너무 과격하게 튐/흔들림 restitution이 과도하거나, 얇은 벽/빠른 속도 restitution을 낮추고, 벽 두께/공 속도를 조절
공을 많이 만들면 급격히 느려짐 물리 계산/충돌 쌍이 증가 개수 제한/초기화 제공, 충돌체 단순화, 필요 시 인스턴싱과는 별개로 물리 최적화 고려


8) 추가로 생각해볼 점

  • “보이는 메시”와 “충돌체”를 분리하는 습관: 실무에서는 렌더 모델(GLB)은 복잡해도, 충돌체는 박스/캡슐/구처럼 단순하게 두는 경우가 많습니다.
  • 단위(스케일) 정리의 중요성: 이전 회차의 Blender 내보내기 규칙(스케일/Apply)이 물리 안정성과 직결됩니다. 스케일이 꼬이면 바운딩과 충돌이 이상해질 수 있습니다.
  • 다음 확장 방향: 충돌 이벤트(어디에 부딪혔는지), 바운스 사운드, 트리거 영역(센서), 캐릭터 컨트롤러 등으로 자연스럽게 확장할 수 있습니다.
  • 측정 기반 운영: 공 개수를 늘릴수록 성능이 어떻게 변하는지(물리 스텝 비용)도 측정 습관을 붙이면, “어느 수준까지가 안전한지”를 프로젝트 기준으로 정할 수 있습니다.


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

Reactions

댓글 쓰기

0 댓글