BabylonJS 15회차 : Skeletal 애니메이션(GLB 캐릭터) — AnimationGroup으로 idle/walk/run 전환하기
3D 캐릭터가 “살아 움직인다”는 느낌은 대부분 스켈레탈(skeletal) 애니메이션에서 나옵니다. GLB(=glTF) 캐릭터에는 보통 skeleton(뼈대)과 애니메이션 클립(Idle/Walk/Run 등)이 함께 들어 있고, BabylonJS는 이를 AnimationGroup 형태로 제공해 줍니다.
이번 회차의 목표는 단순히 “재생”이 아니라, 실무에서 바로 쓰는 방식으로 idle/walk/run을 버튼/키 입력으로 전환하고, 전환 시 부드러운 크로스페이드(cross-fade)까지 적용하는 것입니다. 최종 산출물은 곧바로 프로젝트에 가져다 쓸 수 있는 “캐릭터 애니메이션 컨트롤러”입니다.
요약
- GLB 캐릭터를 로드하면 애니메이션 클립이 AnimationGroup으로 들어옵니다.
- 스켈레톤(skeleton)은 메쉬가 뼈를 따라 변형되는 핵심 구조이며, 여러 애니메이션이 같은 스켈레톤을 공유합니다.
- 전환은 “그냥 stop/start”만 하면 딱딱해질 수 있어 가중치(weight) 기반 크로스페이드로 부드럽게 만듭니다.
- 산출물은 버튼(Idle/Walk/Run) + 키(W/Shift) 입력을 지원하는 애니메이션 컨트롤러입니다.
목차
- 핵심 포인트
- 핵심 개념: AnimationGroup, skeleton
- GLB 캐릭터 로딩 흐름 이해하기
- 실습: idle/walk/run 전환 만들기
- 산출물: “캐릭터 애니메이션 컨트롤러” 구현
- 실행 단계 체크리스트
- 추가로 생각해볼 점
- 블로그 최적화 정보
핵심 포인트
- GLB를 로드하면 scene.animationGroups에 클립들이 들어오며, 보통 이름에
idle,walk,run같은 단서가 있습니다. - skeleton은 뼈대(본/bone) 목록이며, 캐릭터 메쉬는 이 뼈대의 변형을 따라 움직입니다(스키닝/스킨웨이트).
- 전환 품질은 크로스페이드가 좌우합니다. 단순 stop/start 대신, 두 그룹을 동시에 재생하고 가중치(weight)를 1→0, 0→1로 천천히 바꿉니다.
- 실무에서는 “어떤 클립이 들어올지”가 모델마다 다르므로, 이름 매칭 + 안전한 폴백(fallback)이 중요합니다.
핵심 개념: AnimationGroup, skeleton
| 용어 | 쉽게 말하면 | 초보자 체크 포인트 |
|---|---|---|
| skeleton | 캐릭터의 뼈대(본들의 트리 구조) | GLB에 스켈레톤이 없으면 “스켈레탈 애니메이션”이 아닐 수 있습니다. |
| bone | 스켈레톤을 이루는 뼈 하나 | 특정 bone을 잡아 무기/아이템을 붙이는 것도 자주 합니다. |
| skinning | 메쉬가 뼈 움직임에 따라 변형되는 방식 | 어깨/팔꿈치가 이상하게 찌그러지면 웨이트(Weight) 문제일 가능성이 큽니다. |
| AnimationGroup | 하나의 “클립”을 묶어 재생/정지/속도 조절하는 단위 | Idle/Walk/Run이 각자 그룹 1개씩인 경우가 많습니다. |
| weight | 여러 애니메이션을 섞을 때의 영향도(0~1) | 전환을 부드럽게 하려면 weight를 시간에 따라 바꿉니다. |
여기서 가장 중요한 감각은 이겁니다. “AnimationGroup은 버튼으로 켜고 끄는 스위치가 아니라, 섞을 수 있는 레이어”라는 점입니다. 이번 실습에서는 여러 레이어를 섞기 위한 가장 단순하고 안전한 방식으로 크로스페이드를 구현합니다.
GLB 캐릭터 로딩 흐름 이해하기
- GLB 로딩:
BABYLON.SceneLoader.ImportMeshAsync로 메쉬/머티리얼/스켈레톤/애니메이션이 함께 로드됩니다. - AnimationGroup 확보: 로딩 이후
scene.animationGroups를 확인하면 클립 목록이 들어 있습니다. - 이름 매칭: 그룹 이름이
Idle,Walk,Run처럼 딱 맞는 경우도 있지만, 모델마다Armature|mixamo.com|Idle처럼 길고 복잡할 수도 있습니다. - 전환(transition): 원하는 그룹을 재생하되, 기존 그룹을 바로 끊지 말고 가중치로 서서히 교체합니다.
실습: idle/walk/run 전환 만들기
실습은 “한 파일로 실행되는 데모”로 제공합니다. 아래 코드는 다음을 포함합니다.
- GLB 캐릭터 로드(기본값으로 공개 샘플 URL을 넣었고, 여러분의 캐릭터로 쉽게 교체 가능)
- AnimationGroup 자동 탐색: 이름에 idle/walk/run이 들어가면 매칭
- 버튼(Idle/Walk/Run) + 키 입력(W: Walk, Shift+W: Run, 아무 입력 없으면 Idle)
- 크로스페이드 전환(부드러운 느낌)
- 디버그 영역: 로드된 그룹 이름 목록 출력
산출물: “캐릭터 애니메이션 컨트롤러” (완성 코드)
아래 코드를 index.html로 저장해 실행하시면 됩니다. GLB 파일을 직접 쓰려면 CHARACTER_GLB_URL을 여러분의 경로로 바꿔 주세요. (로컬 파일은 브라우저 정책상 로컬 서버에서 실행하는 것이 안전합니다.)
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>BabylonJS Skeletal Animation Controller (GLB)</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:420px;
}
.ui h1 { font-size:14px; margin:0 0 6px 0; }
.hint { font-size:12px; color:#555; line-height:1.35; margin:8px 0 10px; }
.row { display:flex; gap:8px; flex-wrap:wrap; }
button, input {
border:1px solid #ccc; border-radius:10px; padding:10px 12px; background:#fff; cursor:pointer;
font-size:13px;
}
button:active { transform:translateY(1px); }
.small { font-size:12px; color:#444; margin-top:10px; }
.panel {
margin-top:10px; background:#f6f8fa; border:1px solid #ddd; border-radius:10px; padding:8px;
font-size:12px; color:#333; max-height:140px; overflow:auto;
}
.panel code { font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.kbd { padding:1px 6px; border:1px solid #bbb; border-bottom-width:2px; border-radius:6px; background:#fff; font-size:12px; }
</style>
</head>
<body>
<div class="ui">
<h1>캐릭터 애니메이션 컨트롤러 (idle / walk / run)</h1>
<div class="hint">
버튼으로 전환하거나 키보드로 제어합니다.<br>
<span class="kbd">W</span>: Walk, <span class="kbd">Shift + W</span>: Run, 아무 입력 없으면 Idle<br>
전환은 크로스페이드(가중치)로 부드럽게 처리합니다.
</div>
<div class="row">
<button id="idleBtn">Idle</button>
<button id="walkBtn">Walk</button>
<button id="runBtn">Run</button>
<button id="pauseBtn">Pause</button>
</div>
<div class="row" style="margin-top:8px;">
<label style="font-size:12px; color:#555; display:flex; align-items:center; gap:8px;">
Speed
<input id="speedInput" type="number" min="0.1" step="0.1" value="1.0" style="width:90px;" />
</label>
<label style="font-size:12px; color:#555; display:flex; align-items:center; gap:8px;">
Fade(s)
<input id="fadeInput" type="number" min="0" step="0.05" value="0.25" style="width:90px;" />
</label>
</div>
<div class="small" id="statusLine">Loading GLB...</div>
<div class="panel" id="groupsPanel"></div>
</div>
<canvas id="renderCanvas"></canvas>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
<script>
const canvas = document.getElementById("renderCanvas");
const engine = new BABYLON.Engine(canvas, true);
// 여러분 GLB로 교체하세요.
// 로컬이라면 예: "./assets/character.glb" (로컬 서버에서 실행 권장)
// 샘플 URL은 환경에 따라 접근이 제한될 수 있으니, 가능하면 프로젝트에 GLB를 포함하는 것을 권장합니다.
const CHARACTER_GLB_URL = "https://models.babylonjs.com/CesiumMan.glb";
class CharacterAnimationController {
constructor(scene, animationGroups) {
this.scene = scene;
this.groups = animationGroups;
this.map = { idle: null, walk: null, run: null };
this.current = null;
this.isPaused = false;
this.speedRatio = 1.0;
this.fadeSeconds = 0.25;
this._detectDefaultClips();
}
_detectDefaultClips() {
// 이름 기반 자동 매칭 (모델마다 다르므로 최대한 관대하게)
const byName = (re) => this.groups.find(g => re.test((g.name || "").toLowerCase()));
this.map.idle = byName(/\bidle\b|idle_|\|idle|stand|breath|rest/);
this.map.walk = byName(/\bwalk\b|walk_|\|walk/);
this.map.run = byName(/\brun\b|run_|\|run|sprint/);
// 폴백: 못 찾으면 앞에서부터 채우기
const fallback = this.groups.slice(0, 3);
if (!this.map.idle) this.map.idle = fallback[0] || null;
if (!this.map.walk) this.map.walk = fallback[1] || this.map.idle;
if (!this.map.run) this.map.run = fallback[2] || this.map.walk;
// 초기 가중치 0으로 정리(예기치 않은 섞임 방지)
for (const g of this.groups) {
try { g.setWeightForAllAnimatables(0); } catch (e) {}
}
}
setSpeedRatio(ratio) {
this.speedRatio = Math.max(0.05, Number(ratio) || 1.0);
if (this.current) this.current.speedRatio = this.speedRatio;
}
setFadeSeconds(sec) {
this.fadeSeconds = Math.max(0, Number(sec) || 0);
}
pauseToggle() {
this.isPaused = !this.isPaused;
if (!this.current) return;
if (this.isPaused) this.current.pause();
else this.current.play(true);
}
playState(stateKey) {
const next = this.map[stateKey];
if (!next) return;
// 같은 상태면 무시
if (this.current && this.current === next) return;
// 새 그룹이 아직 시작 전이면 시작(루프)
if (!next.isStarted) next.start(true, this.speedRatio);
// 페이드가 0이면 즉시 전환
if (this.fadeSeconds <= 0) {
if (this.current && this.current.isStarted) {
this.current.setWeightForAllAnimatables(0);
this.current.stop();
}
next.setWeightForAllAnimatables(1);
next.speedRatio = this.speedRatio;
this.current = next;
return;
}
// 크로스페이드: old(1->0) / next(0->1)
const old = this.current;
next.speedRatio = this.speedRatio;
next.setWeightForAllAnimatables(0);
const durationMs = this.fadeSeconds * 1000;
const start = performance.now();
const tick = () => {
const t = (performance.now() - start) / durationMs;
const k = Math.min(1, Math.max(0, t)); // 0..1
// 부드러운 곡선(간단한 easeInOut)
const eased = k < 0.5 ? 2*k*k : 1 - Math.pow(-2*k + 2, 2) / 2;
if (old && old.isStarted) old.setWeightForAllAnimatables(1 - eased);
next.setWeightForAllAnimatables(eased);
if (k < 1) {
requestAnimationFrame(tick);
} else {
// 마무리: old 정리
if (old && old.isStarted) {
old.setWeightForAllAnimatables(0);
old.stop();
}
next.setWeightForAllAnimatables(1);
this.current = next;
// 일시정지 상태라면 새 그룹도 pause로 맞추기
if (this.isPaused) next.pause();
}
};
// old가 없다면 next만 페이드 인
if (!old) {
const start2 = performance.now();
const tick2 = () => {
const t = (performance.now() - start2) / durationMs;
const k = Math.min(1, Math.max(0, t));
next.setWeightForAllAnimatables(k);
if (k < 1) requestAnimationFrame(tick2);
else {
next.setWeightForAllAnimatables(1);
this.current = next;
if (this.isPaused) next.pause();
}
};
requestAnimationFrame(tick2);
return;
}
// old도 계속 재생 중이어야 섞이는 느낌이 납니다.
if (!old.isStarted) old.start(true, this.speedRatio);
old.setWeightForAllAnimatables(1);
requestAnimationFrame(tick);
}
debugSummary() {
const n = this.groups.length;
const names = this.groups.map(g => g.name || "(no-name)");
return { count: n, names, map: this.map };
}
}
async function createScene() {
const scene = new BABYLON.Scene(engine);
// 기본 환경
const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI/2, Math.PI/3, 4.5, new BABYLON.Vector3(0, 1.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);
// 카메라가 너무 어둡게 보일 때를 대비한 톤
scene.clearColor = new BABYLON.Color4(0.98, 0.98, 0.985, 1);
// GLB 로드
const statusLine = document.getElementById("statusLine");
const groupsPanel = document.getElementById("groupsPanel");
statusLine.textContent = "Loading GLB...";
const result = await BABYLON.SceneLoader.ImportMeshAsync(
null,
"",
CHARACTER_GLB_URL,
scene
);
// 로드된 루트 정리: 위치/스케일 기본 보정(모델마다 다름)
const rootMeshes = result.meshes.filter(m => !m.parent);
if (rootMeshes[0]) {
rootMeshes[0].position = new BABYLON.Vector3(0, 0, 0);
}
// 캐릭터가 너무 크거나 작으면 scale 조정 (필요 시 값 변경)
// result.meshes[0].scaling = new BABYLON.Vector3(1, 1, 1);
const animationGroups = scene.animationGroups;
// 컨트롤러 생성
const controller = new CharacterAnimationController(scene, animationGroups);
// 디버그 출력
const info = controller.debugSummary();
const mapName = (g) => (g && g.name) ? g.name : "(none)";
groupsPanel.innerHTML =
`<div><strong>AnimationGroups</strong>: <code>${info.count}</code></div>` +
`<div style="margin-top:6px;">` +
`매칭 결과: Idle=<code>${escapeHtml(mapName(info.map.idle))}</code>, ` +
`Walk=<code>${escapeHtml(mapName(info.map.walk))}</code>, ` +
`Run=<code>${escapeHtml(mapName(info.map.run))}</code></div>` +
`<div style="margin-top:8px;"><strong>All Names</strong></div>` +
`<ol style="margin:6px 0 0 18px;">${info.names.map(n => `<li><code>${escapeHtml(n)}</code></li>`).join("")}</ol>`;
statusLine.textContent = "Loaded. Click buttons or use keyboard.";
// 초기 상태: Idle 재생
controller.playState("idle");
// UI 연결
document.getElementById("idleBtn").addEventListener("click", () => controller.playState("idle"));
document.getElementById("walkBtn").addEventListener("click", () => controller.playState("walk"));
document.getElementById("runBtn").addEventListener("click", () => controller.playState("run"));
document.getElementById("pauseBtn").addEventListener("click", () => controller.pauseToggle());
const speedInput = document.getElementById("speedInput");
const fadeInput = document.getElementById("fadeInput");
speedInput.addEventListener("change", () => controller.setSpeedRatio(speedInput.value));
fadeInput.addEventListener("change", () => controller.setFadeSeconds(fadeInput.value));
controller.setSpeedRatio(speedInput.value);
controller.setFadeSeconds(fadeInput.value);
// 키 입력(W, Shift+W)
const key = { w: false, shift: false };
window.addEventListener("keydown", (e) => {
if (e.key === "w" || e.key === "W") key.w = true;
if (e.key === "Shift") key.shift = true;
updateState();
});
window.addEventListener("keyup", (e) => {
if (e.key === "w" || e.key === "W") key.w = false;
if (e.key === "Shift") key.shift = false;
updateState();
});
function updateState() {
if (key.w && key.shift) controller.playState("run");
else if (key.w) controller.playState("walk");
else controller.playState("idle");
}
return scene;
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\"", """)
.replaceAll("'", "'");
}
(async () => {
const scene = await createScene();
engine.runRenderLoop(() => {
scene.render();
});
window.addEventListener("resize", () => engine.resize());
})();
</script>
</body>
</html>
코드 해설: 초보자가 반드시 이해해야 하는 부분
1) 왜 loaders 스크립트가 필요한가
- GLB(glTF)는 BabylonJS 코어만으로는 로더가 포함되지 않는 경우가 많습니다.
- 그래서
babylonjs.loaders.min.js를 함께 로드해야 ImportMeshAsync로 GLB를 불러올 수 있습니다.
2) AnimationGroup 매칭은 “이름”이 전부가 아닙니다
모델에 따라 그룹 이름이 제각각이어서, 컨트롤러는 아래 순서로 찾도록 만들었습니다.
- 그룹 이름에
idle,walk,run같은 키워드가 포함되면 우선 매칭 - 매칭 실패 시 앞에서부터 3개를 idle/walk/run으로 폴백
즉, 여러분이 자신의 캐릭터 GLB를 쓰더라도 “최소한 동작”을 하도록 안전장치를 둔 구조입니다. 다만 실무에서는 그룹 이름을 명확하게 관리하는 편이 유지보수에 유리합니다.
3) 크로스페이드(가중치 전환)가 전환 품질을 만든다
전환이 딱딱한 이유는 대개 “기존 애니메이션을 끊고 다음을 켜기” 때문입니다. 이 글의 컨트롤러는 두 애니메이션을 잠깐 겹치게 재생하고, weight를 시간에 따라 바꿉니다.
- 이전 그룹: weight 1 → 0
- 다음 그룹: weight 0 → 1
이 방식은 캐릭터가 “툭툭 끊겨 보이는 문제”를 크게 줄여 줍니다. 특히 Idle ↔ Walk ↔ Run처럼 빈번한 전환에서는 체감 차이가 뚜렷합니다.
실행 단계 체크리스트
- index.html 파일을 만들고 위 코드를 그대로 붙여 넣습니다.
- 가능하면 로컬 서버에서 실행합니다(예: VS Code Live Server). 파일 더블클릭만으로는 GLB 로드가 막히는 경우가 있습니다.
- 자신의 GLB를 쓰려면
CHARACTER_GLB_URL을./assets/character.glb같은 경로로 바꾸고, 해당 위치에 파일을 둡니다. - 실행 후 패널에 출력되는 AnimationGroup 이름 목록을 확인합니다.
- 버튼(Idle/Walk/Run)과 키 입력(W, Shift+W)으로 전환을 테스트합니다.
- 전환이 너무 빠르거나 느리면 Fade(s) 값을 조정합니다(예: 0.15~0.35 사이에서 많이 사용).
- 동작이 느리거나 빠르면 Speed 값을 조정합니다(예: Run은 1.1~1.4로 올리기도 합니다).
추가로 생각해볼 점
- Root Motion(루트 모션): 일부 캐릭터는 Run 애니메이션 자체가 앞으로 이동(translation)을 포함합니다. 이 경우 “제자리 달리기”가 아니라 캐릭터가 실제로 전진할 수 있습니다.
실무에서는 루트 모션을 쓸지, 물리/이동 로직으로 전진을 처리할지 정책을 정하고 일관되게 가져가는 편이 좋습니다. - 블렌딩 기준: 이번 글은 단일 전환(Idle↔Walk↔Run)을 목표로 합니다. 하지만 실무에서는 “상체 사격” + “하체 달리기”처럼 레이어를 나눠 섞을 수 있습니다.
그 경우에는 bone 마스킹이나 애니메이션 구조 설계가 중요해집니다. - 클립 이름 규칙: Blender/Mixamo/모션캡처 소스마다 이름이 다릅니다. 팀 작업이라면
Idle,Walk,Run같은 규칙을 정해 GLB 내 이름을 통일하는 것이 장기적으로 편합니다. - 전환 이벤트: Run으로 바뀌는 순간 발소리, 먼지 파티클 등을 트리거하려면 애니메이션 이벤트(프레임 이벤트)나 상태 전환 이벤트를 컨트롤러에 추가하는 방식이 깔끔합니다.
- 성능: 크로스페이드는 잠깐 두 애니메이션을 동시에 돌리므로, 극단적으로 많은 레이어를 동시에 섞으면 비용이 커질 수 있습니다. 필요한 만큼만 섞는 습관이 좋습니다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

0 댓글