BabylonJS 기초 11일차 : Gizmo로 이동/회전/스케일 조작 (에디터 같은 조작 맛보기)
한눈에 보는 요약
게임/3D 툴에서 오브젝트를 집어 들고 움직이거나(이동), 방향을 바꾸거나(회전), 크기를 조절하는(스케일) 기능은 “에디터 같은 조작”의 핵심입니다. BabylonJS에서는 이런 조작 핸들을 Gizmo라고 부르고, 여러 Gizmo를 편하게 관리하게 해주는 도구가 GizmoManager입니다.
오늘 목표는 간단합니다. 선택된 오브젝트에 Gizmo를 붙이고, 버튼(또는 단축키)로 이동/회전/스케일 모드를 전환하면서 조작해봅니다. 마지막 산출물은 씬 안에서 오브젝트를 추가하고 선택해서 배치하는 “간단 배치 툴”입니다.
목차
- 핵심 포인트
- 1) Gizmo란 무엇인가
- 2) GizmoManager 한 번에 이해하기
- 3) 구현 흐름: 선택 → Gizmo 부착 → 모드 전환
- 4) 스냅(Snap)으로 “딱딱 맞게” 움직이기
- 5) 초보자가 자주 겪는 문제와 해결
- 6) 실습: 선택된 오브젝트에 Gizmo 붙이기
- 코드 예시: 간단 배치 툴
- 따라하기
- 요약 표
- 블로그 최적화 정보
핵심 포인트
- Gizmo는 오브젝트를 “손으로 잡고 조작하는 핸들”이며 이동/회전/스케일을 직관적으로 제공합니다.
- GizmoManager는 여러 Gizmo를 한 번에 관리하고, 특정 Mesh에 붙였다 떼는 작업을 쉽게 합니다.
- 구현의 핵심 흐름은 “피킹으로 선택 → gizmoManager.attachToMesh(selectedMesh) → 모드 토글”입니다.
- 스냅(Snap)을 켜면 격자처럼 일정 간격으로 이동/회전/스케일이 맞춰져 배치 툴 느낌이 확 살아납니다.
1) Gizmo란 무엇인가
Gizmo는 3D 에디터에서 흔히 보는 빨강/초록/파랑 축(또는 원형 링)으로 된 조작 도구입니다. 마우스로 축을 드래그하면 오브젝트가 그 축 방향으로 움직이고, 링을 드래그하면 회전하며, 큐브 핸들을 드래그하면 크기가 변합니다. 즉, “오브젝트의 transform(변환)을 조작하는 UI”라고 생각하시면 됩니다.
중요한 점은 Gizmo가 “오브젝트를 직접 바꾸는 로직”을 우리가 일일이 구현하지 않게 해준다는 것입니다. 마우스 입력과 드래그 계산, 축 제한 등 골치 아픈 부분을 BabylonJS가 대부분 처리해줍니다. 우리는 “어떤 오브젝트에 붙일지”와 “어떤 모드를 켤지”만 잘 연결하면 됩니다.
2) GizmoManager 한 번에 이해하기
GizmoManager는 말 그대로 Gizmo들을 관리하는 매니저입니다. 이동/회전/스케일 Gizmo를 각각 만들 수도 있지만, 초보 단계에서는 GizmoManager로 시작하는 편이 훨씬 빠르고 안정적입니다.
GizmoManager로 할 수 있는 대표 기능은 다음과 같습니다.
- positionGizmoEnabled / rotationGizmoEnabled / scaleGizmoEnabled로 모드를 켜고 끌 수 있습니다.
- attachToMesh(mesh)로 “현재 조작 대상”을 지정할 수 있습니다(선택 변경 시 여기만 바꾸면 됩니다).
- usePointerToAttachGizmos를 false로 두면, 우리가 만든 선택 로직(피킹)과 깔끔하게 결합할 수 있습니다.
3) 구현 흐름: 선택 → Gizmo 부착 → 모드 전환
오늘 산출물(간단 배치 툴)은 아래 3단계 흐름이 전부라고 해도 과언이 아닙니다.
(1) 선택 클릭 이벤트에서 scene.pick로 Mesh를 선택하고 selectedMesh 변수에 담습니다.
(2) 부착 선택이 생기면 gizmoManager.attachToMesh(selectedMesh)를 호출해 Gizmo를 붙입니다. 빈 공간을 클릭하면 attachToMesh(null)로 떼어냅니다.
(3) 모드 전환 Move/Rotate/Scale 버튼을 눌러 gizmoManager의 enabled 플래그를 토글합니다.
이 구조가 잡히면 다음 단계(복제, 정렬, 충돌 체크, 드래그로 배치 등)로 확장하기가 매우 쉬워집니다.
4) 스냅(Snap)으로 “딱딱 맞게” 움직이기
배치 툴 느낌을 주는 가장 쉬운 장치가 스냅입니다. 예를 들어 이동은 1 유닛 단위로, 회전은 15도 단위로, 스케일은 0.1 단위로만 바뀌게 만들면 “정확히 맞춰 배치”가 가능합니다. BabylonJS Gizmo에는 snapDistance 같은 속성이 있어서 토글로 켜고 끌 수 있습니다.
초보자에게 추천하는 시작값은 이동 1, 회전 15도, 스케일 0.1 정도입니다. 프로젝트 단위(미터/센티)나 오브젝트 크기에 맞춰 조절하시면 됩니다.
5) 초보자가 자주 겪는 문제와 해결
- Gizmo가 안 보입니다: gizmoManager의 position/rotation/scale 중 하나라도 Enabled가 true인지 확인하고, attachToMesh가 null이 아닌지 확인하세요.
- 클릭해도 선택이 안 됩니다: 선택 대상 Mesh의 isPickable=true인지, 바닥(ground) 때문에 항상 다른 걸 맞추고 있지는 않은지 predicate로 점검하세요.
- Gizmo 조작이 너무 민감합니다: 카메라 거리(zoom)와 오브젝트 크기에 따라 체감이 달라질 수 있어 스냅을 켜고, 필요하면 카메라 거리/감도를 조정하세요.
- 회전 축이 이상합니다: 오브젝트의 피벗(pivot)이 모델 중앙에 있지 않을 수 있습니다. pivot을 맞추는 작업은 이후 심화에서 다루되, “왜” 이상해지는지 먼저 인지하는 것이 중요합니다.
6) 실습: 선택된 오브젝트에 Gizmo 붙이기
실습 목표는 아래 3가지입니다. (1) 클릭으로 Mesh를 선택한다. (2) 선택된 Mesh에 Gizmo가 붙는다. (3) 버튼으로 이동/회전/스케일 모드를 바꾼다. 여기에 배치 툴 느낌을 위해 (4) 오브젝트 추가, (5) 스냅 토글, (6) 선택 이름 표시까지 넣어 산출물을 완성합니다.
코드 예시: “간단 배치 툴”
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const createScene = () => {
const scene = new BABYLON.Scene(engine);
// 카메라/라이트
const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI / 2, Math.PI / 2.5, 18, new BABYLON.Vector3(0, 1.5, 0), scene);
camera.attachControl(canvas, true);
const hemi = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), scene);
hemi.intensity = 0.95;
// 바닥(선택 대상 제외)
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 40, height: 40 }, scene);
const groundMat = new BABYLON.StandardMaterial("groundMat", scene);
groundMat.diffuseColor = new BABYLON.Color3(0.18, 0.18, 0.2);
ground.material = groundMat;
ground.isPickable = false;
// 데모 오브젝트 몇 개
const selectable = [];
const makeMat = (name) => {
const m = new BABYLON.StandardMaterial(name + "_mat", scene);
m.diffuseColor = new BABYLON.Color3(Math.random() * 0.8 + 0.2, Math.random() * 0.8 + 0.2, Math.random() * 0.8 + 0.2);
return m;
};
const addBox = (name, pos) => {
const b = BABYLON.MeshBuilder.CreateBox(name, { size: 1.5 }, scene);
b.position = pos.clone();
b.position.y = 0.75;
b.isPickable = true;
b.material = makeMat(name);
selectable.push(b);
return b;
};
const addSphere = (name, pos) => {
const s = BABYLON.MeshBuilder.CreateSphere(name, { diameter: 1.7 }, scene);
s.position = pos.clone();
s.position.y = 0.85;
s.isPickable = true;
s.material = makeMat(name);
selectable.push(s);
return s;
};
addBox("Box_01", new BABYLON.Vector3(-4, 0, -2));
addBox("Box_02", new BABYLON.Vector3(2, 0, -1));
addSphere("Sphere_01", new BABYLON.Vector3(0, 0, 4));
// 선택 하이라이트
const hl = new BABYLON.HighlightLayer("hl", scene);
// GizmoManager
const gizmoManager = new BABYLON.GizmoManager(scene);
gizmoManager.usePointerToAttachGizmos = false; // 우리가 직접 선택 로직으로 붙일 예정
gizmoManager.positionGizmoEnabled = true;
gizmoManager.rotationGizmoEnabled = false;
gizmoManager.scaleGizmoEnabled = false;
// 스냅(배치 툴 느낌 업그레이드)
let snapOn = true;
const applySnap = () => {
const posSnap = snapOn ? 1 : 0;
const rotSnap = snapOn ? BABYLON.Tools.ToRadians(15) : 0;
const sclSnap = snapOn ? 0.1 : 0;
```
if (gizmoManager.gizmos && gizmoManager.gizmos.positionGizmo) gizmoManager.gizmos.positionGizmo.snapDistance = posSnap;
if (gizmoManager.gizmos && gizmoManager.gizmos.rotationGizmo) gizmoManager.gizmos.rotationGizmo.snapDistance = rotSnap;
if (gizmoManager.gizmos && gizmoManager.gizmos.scaleGizmo) gizmoManager.gizmos.scaleGizmo.snapDistance = sclSnap;
```
};
applySnap();
// UI (Babylon GUI)
const ui = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("ui", true, scene);
const panel = new BABYLON.GUI.StackPanel("panel");
panel.width = "240px";
panel.isVertical = true;
panel.horizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT;
panel.verticalAlignment = BABYLON.GUI.Control.VERTICAL_ALIGNMENT_TOP;
panel.paddingTop = "12px";
panel.paddingRight = "12px";
ui.addControl(panel);
const title = new BABYLON.GUI.TextBlock("title", "Placement Tool");
title.height = "28px";
title.color = "white";
title.fontSize = 20;
title.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT;
panel.addControl(title);
const selectedText = new BABYLON.GUI.TextBlock("selectedText", "선택됨: (없음)");
selectedText.height = "22px";
selectedText.color = "#dddddd";
selectedText.fontSize = 14;
selectedText.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT;
panel.addControl(selectedText);
const hint = new BABYLON.GUI.TextBlock("hint", "클릭: 선택 | 빈 공간: 해제");
hint.height = "20px";
hint.color = "#bbbbbb";
hint.fontSize = 12;
hint.textHorizontalAlignment = BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_RIGHT;
panel.addControl(hint);
const makeButton = (label, onClick) => {
const btn = BABYLON.GUI.Button.CreateSimpleButton("btn_" + label, label);
btn.height = "36px";
btn.color = "white";
btn.background = "#2a2a2a";
btn.thickness = 1;
btn.cornerRadius = 6;
btn.paddingTop = "6px";
btn.onPointerUpObservable.add(onClick);
panel.addControl(btn);
return btn;
};
const setMode = (mode) => {
gizmoManager.positionGizmoEnabled = mode === "MOVE";
gizmoManager.rotationGizmoEnabled = mode === "ROTATE";
gizmoManager.scaleGizmoEnabled = mode === "SCALE";
applySnap();
};
// 배치 툴 기능 버튼
makeButton("Move (W)", () => setMode("MOVE"));
makeButton("Rotate (E)", () => setMode("ROTATE"));
makeButton("Scale (R)", () => setMode("SCALE"));
makeButton("Snap: Toggle (Q)", () => { snapOn = !snapOn; applySnap(); });
let spawnId = 100;
makeButton("Add Box (B)", () => {
spawnId += 1;
const b = addBox("Box_" + spawnId, new BABYLON.Vector3(0, 0, 0));
setSelection(b);
});
makeButton("Add Sphere (S)", () => {
spawnId += 1;
const s = addSphere("Sphere_" + spawnId, new BABYLON.Vector3(0, 0, 0));
setSelection(s);
});
makeButton("Delete Selected (Del)", () => { deleteSelected(); });
// 선택 상태
let selectedMesh = null;
const clearSelection = () => {
if (selectedMesh) hl.removeMesh(selectedMesh);
selectedMesh = null;
gizmoManager.attachToMesh(null);
selectedText.text = "선택됨: (없음)";
};
const setSelection = (mesh) => {
if (!mesh) return clearSelection();
if (selectedMesh && selectedMesh !== mesh) hl.removeMesh(selectedMesh);
selectedMesh = mesh;
hl.addMesh(selectedMesh, new BABYLON.Color3(1, 0.9, 0.2));
gizmoManager.attachToMesh(selectedMesh);
selectedText.text = "선택됨: " + selectedMesh.name;
};
const deleteSelected = () => {
if (!selectedMesh) return;
const toDelete = selectedMesh;
clearSelection();
const idx = selectable.indexOf(toDelete);
if (idx >= 0) selectable.splice(idx, 1);
toDelete.dispose();
};
// 피킹 predicate (선택 가능한 것만)
const pickPredicate = (mesh) => {
if (!mesh) return false;
if (mesh === ground) return false;
return mesh.isPickable === true;
};
// 클릭 선택
scene.onPointerObservable.add((pointerInfo) => {
if (pointerInfo.type !== BABYLON.PointerEventTypes.POINTERDOWN) return;
const evt = pointerInfo.event;
if (evt && evt.button !== 0) return; // 좌클릭만
const pick = scene.pick(scene.pointerX, scene.pointerY, pickPredicate);
if (pick && pick.hit && pick.pickedMesh) setSelection(pick.pickedMesh);
else clearSelection();
});
// 단축키(에디터 느낌)
window.addEventListener("keydown", (e) => {
if (e.key === "w" || e.key === "W") setMode("MOVE");
if (e.key === "e" || e.key === "E") setMode("ROTATE");
if (e.key === "r" || e.key === "R") setMode("SCALE");
if (e.key === "q" || e.key === "Q") { snapOn = !snapOn; applySnap(); }
if (e.key === "b" || e.key === "B") { spawnId += 1; setSelection(addBox("Box_" + spawnId, new BABYLON.Vector3(0, 0, 0))); }
if (e.key === "s" || e.key === "S") { spawnId += 1; setSelection(addSphere("Sphere_" + spawnId, new BABYLON.Vector3(0, 0, 0))); }
if (e.key === "Delete") deleteSelected();
if (e.key === "Escape") clearSelection();
});
clearSelection();
return scene;
};
const scene = createScene();
engine.runRenderLoop(() => scene.render());
window.addEventListener("resize", () => engine.resize());
위 예제는 “배치 툴”의 핵심 요소를 한 번에 담았습니다. 클릭으로 Mesh를 선택하면 하이라이트가 켜지고, 그 Mesh에 Gizmo가 붙습니다. W/E/R로 이동/회전/스케일 모드를 바꾸고, Q로 스냅을 켜고 끌 수 있습니다. B/S로 오브젝트를 추가한 뒤 바로 선택 상태로 만들어 배치 흐름을 자연스럽게 연결했습니다. Delete로 선택 삭제, Esc로 선택 해제까지 포함되어 “에디터 같은 조작”을 짧은 코드로 맛볼 수 있습니다.
따라하기
- 씬에 카메라(ArcRotateCamera)와 라이트, 바닥을 만들고 바닥은 isPickable=false로 설정합니다.
- 선택 가능한 Mesh들을 만들고 isPickable=true로 설정하며, 의미 있는 name을 붙여둡니다(나중에 UI 표시와 디버깅이 쉬워집니다).
- GizmoManager를 생성한 뒤 usePointerToAttachGizmos=false로 두고, 기본 모드(예: Move)를 Enabled로 켭니다.
- pointerdown에서 scene.pick를 호출해 선택된 Mesh를 얻고, gizmoManager.attachToMesh(selectedMesh)로 붙입니다(빈 공간 클릭이면 attachToMesh(null)).
- Move/Rotate/Scale 버튼 또는 W/E/R 단축키로 gizmoManager의 Enabled 플래그를 토글합니다.
- 스냅 토글(Q)을 만들어 position/rotation/scale Gizmo의 snapDistance를 켜고 끄며 배치 퀄리티를 올립니다.
- 오브젝트 추가(B/S)와 삭제(Delete), 선택 해제(Esc)를 넣어 “간단 배치 툴”로 마무리합니다.
요약 표
| 기능 | 핵심 API/속성 | 설명 | 추천 단축키 |
|---|---|---|---|
| 오브젝트 선택 | scene.pick(x, y, predicate) | 클릭 위치에서 선택 가능한 Mesh를 찾고 hit/pickedMesh로 분기합니다. | 마우스 좌클릭 |
| Gizmo 부착/해제 | gizmoManager.attachToMesh(mesh) | 선택된 Mesh에 조작 핸들을 붙이고, null이면 해제합니다. | Esc(해제) |
| 이동 모드 | positionGizmoEnabled | 축 드래그로 위치 이동이 가능합니다. | W |
| 회전 모드 | rotationGizmoEnabled | 링 드래그로 회전 조작이 가능합니다. | E |
| 스케일 모드 | scaleGizmoEnabled | 핸들 드래그로 크기 변경이 가능합니다. | R |
| 스냅 토글 | gizmo.snapDistance | 이동/회전/스케일을 일정 단위로 “딱딱” 맞춰 배치할 수 있습니다. | Q |
| 선택 강조 | HighlightLayer.addMesh/removeMesh | 선택된 대상이 한눈에 보이도록 하이라이트를 켭니다. | - |
추가로 생각해볼 점
오늘은 “에디터 같은 조작”의 최소 골격을 만들었습니다. 다음 단계로 확장할 때는 (1) 그리드/스냅 값을 UI로 조절하기, (2) 복제(Duplicate) 기능 추가, (3) 피벗(pivot) 정리로 회전 중심 맞추기, (4) 충돌/바닥 스냅(레이캐스트로 바닥 위에 자동 배치) 같은 기능이 자연스럽습니다. 특히 “선택(selectedMesh) → 조작(Gizmo) → 결과 저장” 흐름이 잡히면, 실제 인게임 빌드 모드나 맵 에디터의 기반으로 그대로 발전시킬 수 있습니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글