BabylonJS 19회차 : 계층 구조/피벗/부모자식
목표: 문/바퀴/로봇팔 같은 구조를 자연스럽게 다룹니다.
핵심 개념: transform hierarchy(부모-자식 변환 전파), pivot 설정(회전 중심)
실습: 차 바퀴 회전 + 차체 이동
산출물: “계층 구조 모델 데모”
목차
- 1) 왜 계층 구조가 중요한가
- 2) 부모-자식 변환 규칙(이동/회전/스케일 전파)
- 3) 피벗(pivot) 이해: “회전 중심”이 틀리면 어색해지는 이유
- 4) 실무 전략: pivot은 어디에서 잡을까?
- 5) 실습: 차체 이동 + 바퀴 회전 데모 만들기
- 6) 트러블슈팅/디버그 체크리스트
- 7) 추가로 생각해볼 점
- 8) 블로그 최적화 정보
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만 연결하는” 파이프라인을 목표로 잡으면 좋습니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글