BabylonJS 14회차 : 기본 애니메이션 시스템 (Animation, Keyframes, Easing)
BabylonJS에서 애니메이션은 “모델 파일에 포함된 애니메이션을 재생”하는 것만 의미하지 않습니다. 오히려 실무에서는 position/rotation 같은 속성을 코드로 직접 애니메이션화하는 일이 자주 생깁니다. 예를 들어 문, 서랍, 레버, 버튼, 엘리베이터, 카메라 이동처럼 “간단하지만 느낌이 중요한” 상호작용은 키프레임과 이징으로 구현하는 편이 빠르고 유연합니다.
이번 글의 목표는 아래 한 문장으로 정리됩니다.
- 버튼 클릭 시 문(door)이 부드럽게 열리도록 rotation 애니메이션을 만들고, easing을 바꿔가며 “체감” 차이를 비교하는 데모를 완성합니다.
목차
- 핵심 개념 한 번에 잡기
- Animation / Keyframes / Easing 쉬운 설명
- 실습: 버튼 클릭으로 문이 부드럽게 열리기
- 산출물: easing 비교 데모 만들기
- 실행 단계 체크리스트
- 추가로 생각해볼 점
- 블로그 최적화 정보
핵심 포인트
- Animation은 “어떤 속성(property)을, 어떤 속도로(FPS), 어떤 방식으로 값이 변하게 할지”를 담는 객체입니다.
- Keyframes는 “몇 프레임(frame)에서 값(value)이 무엇인지”를 정의하는 표준 배열입니다.
- Easing은 “같은 시작/끝 값”이라도 중간 진행(가속/감속 곡선)을 바꿔 체감 품질을 크게 개선합니다.
- 문이 자연스럽게 열리려면 피벗(pivot)을 경첩(힌지) 위치로 옮기는 것이 가장 중요합니다.
- 비교 데모는 easing만 교체할 수 있게 만들어야 학습 효과가 큽니다.
Animation / Keyframes / Easing 쉬운 설명
1) Animation은 “무엇을 애니메이션할지”를 정합니다
BabylonJS의 BABYLON.Animation은 아래 정보를 가집니다.
- 어떤 속성을 움직일지: 예)
position,rotation.y,scaling - 프레임 속도(FPS): 예) 60fps면 60프레임이 1초
- 값 타입: float/vector/quaternion 등
- 루프 모드: 한 번만, 반복, 핑퐁 등
2) Keyframes는 “중요 지점만 찍어두는 지도”입니다
키프레임은 보통 이렇게 구성합니다.
{ frame: 0, value: 시작값 }{ frame: 30, value: 끝값 }
그 사이(1~29프레임)는 BabylonJS가 보간(interpolation)해서 값을 채웁니다. 즉, “중간은 자동으로 계산”되기 때문에 우리는 시작/끝과 필요하면 중간 지점만 정하면 됩니다.
3) Easing은 “느낌(가속/감속)”을 결정합니다
예를 들어 문을 0도에서 90도까지 연다고 가정해 보겠습니다. 선형(Linear)이라면 매 프레임 동일한 각도만큼 움직여서 기계적으로 보일 수 있습니다. 반대로 Ease-In-Out 계열은 시작과 끝에서 느리고 중간에 빠르기 때문에 “손으로 미는 듯한” 느낌을 만들기 좋습니다.
| 개념 | 한 줄 정의 | 이번 실습에서의 예 |
|---|---|---|
| Animation | 어떤 속성을 어떤 FPS로 움직일지 정의 | door의 rotation.y |
| Keyframes | 프레임-값 쌍으로 “중요 지점”만 기록 | 0프레임: 0rad, 30프레임: -90도(rad) |
| Easing | 중간 진행의 가속/감속 곡선을 조절 | Sine/Cubic/Back/Bounce 등 비교 |
실습: 버튼 클릭으로 문이 부드럽게 열리기
실습 시나리오
- 문(door) 메시는 박스(Box)로 만들고, 경첩이 있는 왼쪽 가장자리를 기준으로 회전하도록 피벗을 옮깁니다.
- 버튼을 누르면
rotation.y가 0 → -90도로 애니메이션됩니다. - 닫기 버튼을 누르면 -90도 → 0도로 되돌립니다.
가장 흔한 실수: 피벗을 옮기지 않으면 문이 “중앙에서 빙글” 돕니다
문은 경첩을 기준으로 회전합니다. 따라서 피벗을 경첩 위치로 이동하지 않으면, 문이 중앙에서 회전해 어색해집니다. BabylonJS에서는 setPivotPoint로 이를 해결할 수 있습니다.
완성 코드(HTML 한 파일로 실행)
아래 코드는 easing 선택(드롭다운) + 열기/닫기 버튼 + 자동 비교 버튼까지 포함한 “easing 비교 데모”입니다. 그대로 index.html로 저장해 실행해 보세요.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>BabylonJS Animation: Door + Easing Demo</title>
<style>
html, body { width:100%; height:100%; margin:0; overflow:hidden; font-family:system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
#renderCanvas { width:100%; height:100%; touch-action:none; display:block; }
.ui {
position:fixed; left:16px; top:16px; z-index:10;
background:rgba(255,255,255,0.92); border:1px solid #ddd; border-radius:12px;
padding:12px; box-shadow:0 8px 24px rgba(0,0,0,0.12); max-width:360px;
}
.ui h1 { font-size:14px; margin:0 0 8px 0; }
.row { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
select, button {
border:1px solid #ccc; border-radius:10px; padding:10px 12px; background:#fff; cursor:pointer;
font-size:13px;
}
button:active { transform:translateY(1px); }
.hint { font-size:12px; color:#555; margin-top:8px; line-height:1.35; }
.log { font-size:12px; color:#333; margin-top:10px; background:#f6f8fa; border:1px solid #ddd; border-radius:10px; padding:8px; }
.log code { font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
</style>
</head>
<body>
<div class="ui">
<h1>Door Animation + Easing 비교 데모</h1>
<div class="row">
<select id="easingSelect">
<option value="Linear">Linear (기계적)</option>
<option value="SineInOut" selected>Sine EaseInOut (자연스러움)</option>
<option value="CubicInOut">Cubic EaseInOut (탄탄한 가속/감속)</option>
<option value="BackOut">Back EaseOut (살짝 튕김)</option>
<option value="BounceOut">Bounce EaseOut (통통 튐)</option>
</select>
</div>
<div class="row">
<button id="openBtn">문 열기</button>
<button id="closeBtn">문 닫기</button>
<button id="compareBtn">자동 비교</button>
</div>
<div class="hint">
같은 시작/끝 값(0↔-90도)이라도 easing에 따라 “중간 진행(가속/감속)”이 달라집니다.<br>
자동 비교는 여러 easing을 순서대로 적용해 체감 차이를 빠르게 보여줍니다.
</div>
<div class="log" id="logBox">현재 easing: <code>SineInOut</code></div>
</div>
<canvas id="renderCanvas"></canvas>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
const FPS = 60;
const DURATION_FRAMES = 30; // 60fps 기준 약 0.5초
const OPEN_ANGLE = -Math.PI / 2; // -90도
function createScene() {
const scene = new BABYLON.Scene(engine);
// 카메라/라이트
const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI/2, Math.PI/3, 8, new BABYLON.Vector3(0, 1, 0), scene);
camera.attachControl(canvas, true);
const light = new BABYLON.HemisphericLight("hlight", new BABYLON.Vector3(0, 1, 0), scene);
light.intensity = 0.95;
// 바닥/벽(간단한 공간감)
const ground = BABYLON.MeshBuilder.CreateGround("ground", { width: 10, height: 10 }, scene);
const wall = BABYLON.MeshBuilder.CreateBox("wall", { width: 6, height: 3, depth: 0.2 }, scene);
wall.position = new BABYLON.Vector3(0, 1.5, -2);
const wallMat = new BABYLON.StandardMaterial("wallMat", scene);
wallMat.diffuseColor = new BABYLON.Color3(0.92, 0.92, 0.94);
wall.material = wallMat;
// 문(door): 경첩이 왼쪽에 있다고 가정
const doorWidth = 1.2;
const doorHeight = 2.2;
const doorDepth = 0.08;
const door = BABYLON.MeshBuilder.CreateBox("door", { width: doorWidth, height: doorHeight, depth: doorDepth }, scene);
// 문 재질
const doorMat = new BABYLON.StandardMaterial("doorMat", scene);
doorMat.diffuseColor = new BABYLON.Color3(0.55, 0.35, 0.22);
door.material = doorMat;
// 문 위치: 벽 앞쪽
door.position = new BABYLON.Vector3(-1.0, doorHeight/2, -1.85);
// 핵심: 피벗을 왼쪽 모서리(경첩)로 이동
// 박스 중심이 (0,0,0)이므로 왼쪽 끝은 -doorWidth/2
door.setPivotPoint(new BABYLON.Vector3(-doorWidth/2, 0, 0));
// 문 손잡이(옵션): position 애니메이션 예시로 살짝 튀게 만들 수 있음
const knob = BABYLON.MeshBuilder.CreateSphere("knob", { diameter: 0.08 }, scene);
knob.parent = door;
knob.position = new BABYLON.Vector3(doorWidth/2 - 0.12, 0, doorDepth/2 + 0.05);
const knobMat = new BABYLON.StandardMaterial("knobMat", scene);
knobMat.diffuseColor = new BABYLON.Color3(0.95, 0.85, 0.25);
knob.material = knobMat;
// 상태
let isOpen = false;
let currentAnimatable = null;
function easingFromName(name) {
// Linear는 easing을 쓰지 않아도 되지만, 비교를 위해 LinearEase를 사용합니다.
if (name === "Linear") {
const e = new BABYLON.LinearEase();
e.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
return e;
}
if (name === "SineInOut") {
const e = new BABYLON.SineEase();
e.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
return e;
}
if (name === "CubicInOut") {
const e = new BABYLON.CubicEase();
e.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
return e;
}
if (name === "BackOut") {
const e = new BABYLON.BackEase(0.8); // overshoot 강도
e.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEOUT);
return e;
}
if (name === "BounceOut") {
const e = new BABYLON.BounceEase(3, 2); // bounces, bounciness
e.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEOUT);
return e;
}
const fallback = new BABYLON.SineEase();
fallback.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
return fallback;
}
function playDoorRotation(toOpen, easingName) {
// 중복 재생 방지: 진행 중이면 중단
if (currentAnimatable) {
currentAnimatable.stop();
currentAnimatable = null;
}
const from = door.rotation.y;
const to = toOpen ? OPEN_ANGLE : 0;
const anim = new BABYLON.Animation(
"doorRotY",
"rotation.y",
FPS,
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
);
const keys = [
{ frame: 0, value: from },
{ frame: DURATION_FRAMES, value: to }
];
anim.setKeys(keys);
const easing = easingFromName(easingName);
anim.setEasingFunction(easing);
// 애니메이션 시작
currentAnimatable = scene.beginDirectAnimation(door, [anim], 0, DURATION_FRAMES, false, 1.0, () => {
currentAnimatable = null;
isOpen = toOpen;
});
// 손잡이 position 예시(가벼운 피드백): 클릭 시 살짝 앞으로 튀는 효과
// "position.z"를 아주 짧게 움직이면 상호작용 느낌이 좋아집니다.
const knobAnim = new BABYLON.Animation(
"knobZ",
"position.z",
FPS,
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT
);
const kzFrom = knob.position.z;
const kzTo = kzFrom + 0.03;
knobAnim.setKeys([
{ frame: 0, value: kzFrom },
{ frame: Math.floor(DURATION_FRAMES * 0.25), value: kzTo },
{ frame: Math.floor(DURATION_FRAMES * 0.5), value: kzFrom }
]);
const knobEase = new BABYLON.SineEase();
knobEase.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
knobAnim.setEasingFunction(knobEase);
scene.beginDirectAnimation(knob, [knobAnim], 0, Math.floor(DURATION_FRAMES * 0.5), false, 1.0);
}
// UI 연결
const easingSelect = document.getElementById("easingSelect");
const logBox = document.getElementById("logBox");
function setLog(name) {
logBox.innerHTML = `현재 easing: <code>${name}</code>`;
}
document.getElementById("openBtn").addEventListener("click", () => {
const easingName = easingSelect.value;
setLog(easingName);
playDoorRotation(true, easingName);
});
document.getElementById("closeBtn").addEventListener("click", () => {
const easingName = easingSelect.value;
setLog(easingName);
playDoorRotation(false, easingName);
});
document.getElementById("compareBtn").addEventListener("click", async () => {
const order = ["Linear", "SineInOut", "CubicInOut", "BackOut", "BounceOut"];
// 비교는 보기 좋게: 열기(각 easing) → 닫기(각 easing)
for (const name of order) {
easingSelect.value = name;
setLog(name);
playDoorRotation(true, name);
await waitMs(700);
playDoorRotation(false, name);
await waitMs(700);
}
});
function waitMs(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
return scene;
}
const scene = createScene();
engine.runRenderLoop(() => {
scene.render();
});
window.addEventListener("resize", () => engine.resize());
</script>
</body>
</html>
코드 읽는 방법(초보자용 가이드)
- 피벗 설정:
door.setPivotPoint(new BABYLON.Vector3(-doorWidth/2, 0, 0));
문 박스의 “왼쪽 끝”을 회전 기준으로 바꿉니다. 이 한 줄이 문 애니메이션의 절반입니다. - Animation 생성:
new BABYLON.Animation(..., "rotation.y", ...)
회전의 y축 값만(float) 애니메이션하겠다는 뜻입니다. - 키프레임:
frame:0에서 현재 각도,frame:30에서 목표 각도(열기면 -90도) - 이징 교체:
anim.setEasingFunction(easing)
선형, Sine, Cubic, Back, Bounce를 바꿔가며 체감 차이를 확인합니다. - 재생:
scene.beginDirectAnimation(door, [anim], 0, DURATION_FRAMES, false)
문(door) 객체에 애니메이션을 직접 실행합니다.
산출물: easing 비교 데모 포인트
위 코드의 “자동 비교” 버튼이 바로 산출물입니다. 핵심은 목표 각도(0↔-90도)와 시간(프레임)은 고정하고, easing만 교체해 차이를 느끼게 하는 것입니다.
어떤 easing이 언제 좋을까요?
| Easing | 체감 특징 | 추천 상황 | 주의점 |
|---|---|---|---|
| Linear | 일정 속도(기계적) | 디버깅, 기준선 비교 | 실감이 떨어질 수 있음 |
| Sine EaseInOut | 부드러운 가속/감속 | 문/카메라/UI 등 대부분 | 너무 얌전하게 느껴질 때도 있음 |
| Cubic EaseInOut | 더 또렷한 속도 변화 | ‘힘’이 느껴져야 하는 동작 | 짧은 시간엔 급하게 느껴질 수 있음 |
| Back EaseOut | 끝에서 살짝 오버슈트 | 버튼/레버/장난감 같은 연출 | 문에는 과하면 부자연스러움 |
| Bounce EaseOut | 튕기며 멈춤 | 캐주얼, 코믹한 상호작용 | 현실적인 문/기계에는 부적합한 경우 |
실행 단계 체크리스트
- 빈 폴더를 만들고
index.html파일을 생성합니다. - 위 코드를 그대로 붙여 넣고 저장합니다.
- 로컬 서버로 실행합니다(브라우저의 보안 정책 때문에 파일 더블클릭보다 서버 실행을 권장합니다).
예: VS Code의 Live Server 확장 또는 간단한 로컬 서버를 사용합니다. - 브라우저에서 열고, 드롭다운으로 easing을 바꾼 뒤 문 열기/닫기를 눌러 체감 차이를 확인합니다.
- 자동 비교를 눌러 여러 easing을 연속 재생하며 “느낌”을 빠르게 익힙니다.
추가로 생각해볼 점
- Animation 재사용 vs 매번 생성: 데모에서는 이해를 위해 매번 새 Animation을 만들지만, 실무에선 재사용하거나
AnimationGroup으로 묶어 관리하는 방법도 고려할 수 있습니다. - 프레임 기반 vs 시간 기반: 예제는 프레임(0~30) 기준입니다. 프레임 길이는 FPS와 함께 “시간”을 결정하므로, FPS/프레임 수를 바꾸면 체감 속도가 바뀝니다.
- 회전 표현 방식: 단순한 문은
rotation.y로 충분하지만, 복잡한 회전(누적/특수 축)이 필요하면Quaternion기반 애니메이션을 고려합니다. - 상태 관리: 클릭 연타 시 애니메이션이 겹치지 않도록
Animatable.stop()처리처럼 “중복 재생 방지”를 습관화하면 버그가 줄어듭니다. - 자연스러움의 핵심: 모델링이 단순해도 easing과 피벗만 잘 잡으면 결과물 품질이 크게 올라갑니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글