BabylonJS 19회차 : 계층 구조/피벗/부모자식



BabylonJS 19회차 : 계층 구조/피벗/부모자식

목표: 문/바퀴/로봇팔 같은 구조를 자연스럽게 다룹니다.
핵심 개념: transform hierarchy(부모-자식 변환 전파), pivot 설정(회전 중심)
실습: 차 바퀴 회전 + 차체 이동
산출물: “계층 구조 모델 데모”


목차


1) 왜 계층 구조가 중요한가

3D에서 “자연스러운 움직임”은 대부분 부모-자식(계층 구조)로 만들어집니다. 예를 들어 차를 생각해보면:

  • 차체가 이동하면 바퀴도 함께 이동해야 합니다.
  • 하지만 바퀴는 이동뿐 아니라 “자기 축을 중심으로 회전”해야 합니다.
  • 즉, 바퀴는 차체의 영향을 받으면서도(부모), 자기만의 회전(자식)을 추가로 해야 합니다.

로봇팔, 문짝(힌지), 포탑(회전대), 캐릭터 본 구조 등도 모두 같은 원리로 동작합니다. 이번 회차의 목표는 “코드로 계층을 만들고, 피벗을 올바르게 잡아 움직임을 자연스럽게 만드는 방법”을 초보자 관점에서 확실히 잡는 것입니다.


2) 부모-자식 변환 규칙(이동/회전/스케일 전파)

BabylonJS에서 메시 A를 메시 B의 자식으로 만들면(=A.parent = B), A는 B의 변환을 그대로 물려받습니다.

2-1) 가장 중요한 규칙 3가지

  • 부모가 움직이면 자식도 같이 움직입니다. (이동/회전/스케일 모두 전파)
  • 자식의 position/rotation은 “부모 기준(local)”입니다. 즉, 세계 좌표(world)가 아니라 “부모 좌표계”에서의 값입니다.
  • 자식은 부모의 변환 위에 “추가 변환”을 얹을 수 있습니다. 바퀴는 차체 이동(부모) + 바퀴 회전(자식)을 동시에 수행합니다.

2-2) local / world 감각

계층 구조에서 혼란이 생기는 대표 원인은 “내가 만지는 값이 local인가 world인가”를 놓치는 것입니다. 간단히 기억하시면 좋습니다.

  • mesh.position, mesh.rotation은 기본적으로 로컬(local)입니다.
  • 월드 좌표가 필요할 때는 mesh.getAbsolutePosition() 같은 API를 사용해 결과를 조회합니다.
  • “부모를 바꿀 때”는 상대 좌표가 달라지므로, 기존 위치가 변한 것처럼 보일 수 있습니다.


3) 피벗(pivot) 이해: “회전 중심”이 틀리면 어색해지는 이유

피벗(pivot)은 회전(그리고 스케일)의 기준점입니다. 문짝은 힌지(경첩) 위치를 중심으로 돌아야 자연스럽고, 바퀴는 바퀴 중심 축을 기준으로 돌아야 합니다.

3-1) 피벗이 틀린 대표 증상

  • 바퀴가 “제자리 회전”이 아니라 공중에서 원을 그리며 빙빙 돕니다.
  • 문짝이 경첩이 아니라 문 중앙에서 돌아서 어색합니다.
  • 로봇팔 관절이 관절 위치가 아니라 팔 중간에서 꺾입니다.

3-2) BabylonJS에서 피벗을 다루는 방법 2가지

실무에서 많이 쓰는 방식은 아래 둘입니다. 초보자 단계에서는 “둘이 같은 목적을 다른 방식으로 달성한다” 정도만 이해하셔도 충분합니다.

방식 핵심 아이디어 장점 주의
A) 피벗을 직접 설정
setPivotPoint / setPivotMatrix
메시 자체의 회전 중심을 바꿈 메시 하나로 해결 가능 좌표계/로컬 기준 이해가 필요, 디버깅이 어려울 수 있음
B) “피벗용 TransformNode”를 둠 비어있는 부모(피벗 노드)를 만들고, 자식 메시를 오프셋 배치 시각적/논리적으로 직관적, 계층 구조와 잘 맞음 노드가 하나 더 생기지만 관리가 쉬움

이번 실습에서는 초보자 친화적으로 TransformNode(피벗 노드) 방식을 사용합니다. 이유는 “바퀴/문/관절” 같은 구조를 확장하기 쉽고, 계층 구조를 배우는 목적과도 잘 맞기 때문입니다.


4) 실무 전략: pivot은 어디에서 잡을까?

피벗은 “엔진에서 바꿀 수도” 있고 “Blender에서 미리 정리할 수도” 있습니다. 초보자 기준으로는 아래 원칙을 권장합니다.

  • 모델러(Blender)에서 오리진을 올바르게: 바퀴 중심, 문 힌지 같은 핵심 피벗은 가능하면 제작 단계에서 맞춰두면 엔진 작업이 단순해집니다.
  • 엔진에서는 TransformNode로 보정: 이미 받은 모델을 수정하기 어렵거나, 여러 파츠를 조립하는 경우는 TransformNode를 피벗으로 두고 “오프셋”으로 맞추는 편이 안전합니다.
  • 한 프로젝트에서 규칙을 하나로 통일: 어떤 팀은 “무조건 Blender에서 오리진 정리”를 규칙으로 하고, 어떤 팀은 “엔진에서 피벗 노드로 조립”을 규칙으로 합니다. 섞이면 디버깅이 어려워집니다.


5) 실습: 차체 이동 + 바퀴 회전 데모 만들기

실습 목표는 간단합니다.

  • 차체(carRoot)가 앞으로 이동한다.
  • 바퀴(wheelPivot) 4개가 차체 이동을 따라가며, 동시에 자기 축으로 회전한다.

이 실습은 “메시를 직접 만들기”로도 가능하고, GLB로 가져온 모델 파츠를 연결하는 방식으로도 확장할 수 있습니다. 여기서는 초보자도 바로 실행 가능한 형태로, BabylonJS 기본 박스/실린더로 차를 구성합니다.

5-1) 완성 코드: “계층 구조 모델 데모”

아래 코드는 그대로 index.html로 실행할 수 있습니다. (정적 서버 권장) 키 입력으로 이동/회전이 동작하며, 바퀴 회전은 이동량에 비례해 계산됩니다.

<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>BabylonJS Hierarchy & Pivot Demo - Car Wheels</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;
      padding: 10px 12px;
      border-radius: 12px;
      background: rgba(0,0,0,0.55);
      color: rgba(255,255,255,0.92);
      font-size: 13px;
      line-height: 1.45;
      border: 1px solid rgba(255,255,255,0.18);
      z-index: 10;
      max-width: 360px;
    }
    #hud code { background: rgba(255,255,255,0.12); padding: 1px 6px; border-radius: 6px; }
  </style>
  <script src="https://cdn.babylonjs.com/babylon.js"></script>
</head>
<body>
  <div id="hud">
    <div><strong>BabylonJS 19회차 데모</strong> - 계층 구조/피벗/부모자식</div>
    <div>이동: <code>W</code>(전진), <code>S</code>(후진) / 조향: <code>A</code>(좌), <code>D</code>(우)</div>
    <div>핵심: 차체(root)가 움직이면 바퀴(pivot+mesh)가 함께 이동하고, 바퀴는 추가로 회전합니다.</div>
  </div>

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

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

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

      // 카메라: 모델 뷰어 감각으로 차를 관찰
      const camera = new BABYLON.ArcRotateCamera(
        "camera", Math.PI / 2, Math.PI / 2.6, 10,
        new BABYLON.Vector3(0, 1, 0), scene
      );
      camera.attachControl(canvas, true);
      camera.wheelDeltaPercentage = 0.01;

      // 라이트
      const hemi = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), scene);
      hemi.intensity = 1.0;

      // 바닥
      const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 30, height: 30 }, scene);
      ground.position.y = 0;
      const gmat = new BABYLON.StandardMaterial("gmat", scene);
      gmat.alpha = 0.18;
      ground.material = gmat;
      ground.isPickable = false;

      // -----------------------------
      // 1) 계층 구조의 루트: carRoot
      // -----------------------------
      // carRoot는 TransformNode로 두면 “차 전체”를 한 덩어리처럼 다루기 쉽습니다.
      const carRoot = new BABYLON.TransformNode("carRoot", scene);
      carRoot.position = new BABYLON.Vector3(0, 0.45, 0);

      // 차체(시각적 메시) - carRoot의 자식
      const body = BABYLON.MeshBuilder.CreateBox("body", { width: 2.6, height: 0.6, depth: 1.2 }, scene);
      body.parent = carRoot;
      body.position = new BABYLON.Vector3(0, 0.35, 0);

      const bodyMat = new BABYLON.StandardMaterial("bodyMat", scene);
      bodyMat.diffuseColor = new BABYLON.Color3(0.65, 0.75, 0.95);
      body.material = bodyMat;

      // 휠 공통 설정
      const wheelRadius = 0.32;
      const wheelWidth = 0.22;

      function createWheel(name, localPos) {
        // -----------------------------
        // 2) 피벗 노드: wheelPivot
        // -----------------------------
        // wheelPivot은 “바퀴 회전 중심” 역할을 합니다.
        // 바퀴 메시를 pivot의 자식으로 두고, 필요하면 메시를 offset시켜 중심을 맞춥니다.
        const wheelPivot = new BABYLON.TransformNode(name + "_pivot", scene);
        wheelPivot.parent = carRoot;
        wheelPivot.position = localPos.clone();

        // 바퀴 메시(실린더)
        const wheel = BABYLON.MeshBuilder.CreateCylinder(name, {
          diameter: wheelRadius * 2,
          height: wheelWidth,
          tessellation: 28
        }, scene);

        wheel.parent = wheelPivot;

        // BabylonJS 기본 Cylinder는 Y축이 길이 방향입니다.
        // 자동차 바퀴처럼 X축(또는 Z축) 방향으로 두려면 회전시켜야 합니다.
        wheel.rotation.z = Math.PI / 2;

        const wmat = new BABYLON.StandardMaterial(name + "_mat", scene);
        wmat.diffuseColor = new BABYLON.Color3(0.18, 0.18, 0.2);
        wheel.material = wmat;

        return { wheelPivot, wheel };
      }

      // 바퀴 4개: 로컬 좌표는 carRoot 기준
      const wheels = [];
      wheels.push(createWheel("wheelFL", new BABYLON.Vector3(-1.05, 0.0,  0.65))); // Front-Left
      wheels.push(createWheel("wheelFR", new BABYLON.Vector3( 1.05, 0.0,  0.65))); // Front-Right
      wheels.push(createWheel("wheelRL", new BABYLON.Vector3(-1.05, 0.0, -0.65))); // Rear-Left
      wheels.push(createWheel("wheelRR", new BABYLON.Vector3( 1.05, 0.0, -0.65))); // Rear-Right

      // -----------------------------
      // 3) 입력 및 애니메이션(매 프레임 업데이트)
      // -----------------------------
      const keys = { w:false, a:false, s:false, d:false };
      window.addEventListener("keydown", (e) => {
        const k = e.key.toLowerCase();
        if (k in keys) keys[k] = true;
      });
      window.addEventListener("keyup", (e) => {
        const k = e.key.toLowerCase();
        if (k in keys) keys[k] = false;
      });

      // 차량 상태
      let speed = 0;               // 현재 속도(전진 +, 후진 -)
      const accel = 0.04;          // 가속
      const friction = 0.90;       // 감속(마찰)
      const maxSpeed = 0.35;

      let steer = 0;               // 조향(회전) 입력
      const steerSpeed = 0.025;

      // 바퀴 회전 누적(라디안)
      let wheelSpin = 0;

      scene.onBeforeRenderObservable.add(() => {
        // 입력 기반 속도 업데이트
        if (keys.w) speed += accel;
        if (keys.s) speed -= accel;
        speed = Math.max(-maxSpeed, Math.min(maxSpeed, speed));
        speed *= friction; // 입력 없을 때 자연스럽게 느려짐

        // 조향 업데이트
        if (keys.a) steer += steerSpeed;
        if (keys.d) steer -= steerSpeed;
        steer *= 0.85; // 손을 떼면 조향도 서서히 0으로

        // -----------------------------
        // 4) 부모(carRoot)에 이동/회전 적용
        // -----------------------------
        // 차는 자기 기준 앞으로 이동해야 하므로 local 방향 벡터를 사용합니다.
        carRoot.rotation.y += steer * (Math.abs(speed) + 0.02);

        const forward = new BABYLON.Vector3(0, 0, 1);
        const worldForward = BABYLON.Vector3.TransformNormal(forward, carRoot.getWorldMatrix());
        worldForward.y = 0;
        worldForward.normalize();

        carRoot.position.addInPlace(worldForward.scale(speed));

        // -----------------------------
        // 5) 바퀴 회전(자식에 추가 변환)
        // -----------------------------
        // “이동 거리 / 바퀴 반지름 = 회전 라디안” 근사
        const distance = speed; // 프레임당 이동량(대략)
        wheelSpin += distance / Math.max(0.0001, wheelRadius);

        // 각 바퀴는 wheelPivot 아래의 wheel 메시를 회전시킵니다.
        // 실린더를 z로 눕혀놨기 때문에, 바퀴 굴림 회전 축은 x(또는 y)가 아니라 상황에 맞게 선택해야 합니다.
        // 여기서는 wheel.rotation.x를 굴림 축으로 사용합니다.
        wheels.forEach(({ wheel }) => {
          wheel.rotation.x = wheelSpin;
        });
      });

      return scene;
    }

    const scene = createScene();
    engine.runRenderLoop(() => scene.render());
    window.addEventListener("resize", () => engine.resize());
  </script>
</body>
</html>

5-2) 코드 핵심 해설(초보자용)

  • carRoot가 “차 전체”를 대표하는 부모 노드: 차체 메시와 바퀴 피벗이 모두 carRoot의 자식입니다. 그래서 carRoot가 이동하면 모두 함께 이동합니다.
  • wheelPivot은 “피벗 역할만” 하는 빈 노드: 바퀴 메시를 pivot의 자식으로 둬서, pivot 기준으로 회전시키는 구조가 됩니다.
  • 바퀴 회전은 자식에만 추가: 차체 이동/회전은 부모에서 처리하고, 바퀴 굴림은 wheel.rotation.x 같은 자식 변환으로만 처리합니다. 이 분리가 계층 구조의 핵심입니다.
  • 굴림 회전량 계산의 직관: “바퀴가 굴러간 거리 ÷ 반지름 = 회전 라디안”으로 근사하면, 속도가 빨라질수록 회전도 빨라져 자연스러운 느낌이 납니다.

5-3) GLB 파츠(차체/바퀴)로 확장하는 방법

실제 프로젝트에서는 박스/실린더 대신 GLB에서 차체와 바퀴 메시를 찾아 계층을 연결하는 방식이 많습니다. 핵심은 동일합니다.

  • GLB 로딩 후 scene.getMeshByName("Wheel_FL")처럼 바퀴 메시를 찾습니다.
  • 각 바퀴 위치에 TransformNode(피벗)를 만들고, 바퀴 메시를 그 자식으로 붙입니다.
  • 차체 루트(부모)를 하나 정해 차 전체를 통째로 움직이게 합니다.


6) 트러블슈팅/디버그 체크리스트

계층/피벗 문제는 “원인은 단순한데, 증상이 이상하게 보이는” 경우가 많습니다. 아래 체크리스트로 빠르게 진단합니다.

증상 가능 원인 빠른 해결
바퀴가 제자리에서 안 돌고 궤도를 그리며 돔 피벗(오리진)이 중심이 아님, 자식 오프셋이 틀림 TransformNode를 피벗으로 두고, 바퀴 메시 위치/회전 오프셋을 재정렬
부모를 움직였더니 자식 위치가 ‘바뀐 것처럼’ 보임 로컬 좌표 기준이 부모로 바뀜 부모 지정 전후의 position 의미를 구분, 필요하면 attach 전 월드 좌표를 저장해 보정
바퀴 회전 축이 이상함(옆으로 굴러감) 실린더 방향/축 회전이 맞지 않음 바퀴 메시 기본 방향을 먼저 잡고(예: rotation.z = PI/2), 굴림 축을 다시 선택
모델이 갑자기 멀리 날아감/스케일이 이상함 부모 스케일/회전이 비정상(Apply 미적용), 피벗 매트릭스 꼬임 부모 루트는 TransformNode로 단순화, Blender에서 Rotation/Scale Apply 확인


7) 추가로 생각해볼 점

  • 문/로봇팔은 “회전축”이 더 중요: 바퀴는 축이 단순하지만, 문/관절은 힌지 축(어느 방향으로 도는지)까지 정해야 자연스럽습니다.
  • 계층 구조가 깊어질수록 규칙이 더 필요: 로봇팔처럼 3~6단 관절이 연결되면, 각 관절 노드의 역할(피벗/메시)을 명확히 나누는 것이 디버깅에 유리합니다.
  • 엔진에서 피벗을 직접 바꾸는 방식은 ‘고급 편’: setPivotMatrix는 강력하지만 좌표계 이해가 필요합니다. 초보자 단계에서는 TransformNode 방식이 안전합니다.
  • GLB 제작 단계(Blender)와의 연결: 다음 단계로는 “Blender에서 바퀴 오리진을 중심으로 정리하고, 엔진에서는 단순히 parent만 연결하는” 파이프라인을 목표로 잡으면 좋습니다.

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

Reactions

댓글 쓰기

0 댓글