-
Spine 애니메이션 블렌딩 시스템과 선형보간에 대하여typescript 2025. 7. 15. 10:44
목차

개요
게임 개발에서 캐릭터 애니메이션의 자연스러운 전환은 사용자 경험에 큰 영향을 미칩니다. 갑작스러운 애니메이션 전환은 부자연스러운 느낌을 주지만, 적절한 블렌딩을 통해 부드럽고 자연스러운 전환을 구현할 수 있습니다.
이 글에서는 Spine 애니메이션 엔진을 활용한 블렌딩 시스템과 그 핵심인 선형보간(Linear Interpolation, LERP)에 대해 깊이 있게 다루어보겠습니다.
Spine 블렌딩 시스템 이해
블렌딩이란?
블렌딩은 두 애니메이션 간의 부드러운 전환을 위한 기술입니다. 한 애니메이션에서 다른 애니메이션으로 전환할 때 갑작스러운 점프가 아닌 자연스러운 보간 효과를 제공합니다.
Spine의 내장 블렌딩 시스템
Spine은 복잡한 수학적 계산을 직접 구현할 필요 없이 내장 블렌딩 시스템을 제공합니다.
// Spine의 기본 믹스 설정 public setMix($anim0: string, $anim1: string, $time: number): void { this.ani.animationState.data.setMix($anim0, $anim1, $time); }핵심 포인트:
animationState.data.setMix(): Spine 라이브러리의 표준 블렌딩 메서드- AnimationStateData에 믹스 정보가 매핑 테이블로 저장
- 양방향 설정 가능: A→B와 B→A는 별도로 설정
블렌딩 데이터 구조
// Spine 객체 초기화 this.ani = $scene.add.spine(0, 0, spineData, spineAtlas); this._skeletonData = this.ani.skeleton.data;AnimationStateData에 저장되는 정보:
- 애니메이션 A → 애니메이션 B: 전환 시간 매핑
- 모든 가능한 애니메이션 조합에 대한 블렌딩 설정
- 런타임에서 자동 참조하여 블렌딩 수행
선형보간(LERP)의 수학적 원리
기본 개념
선형보간은 두 값 사이를 직선으로 연결하여 중간값을 계산하는 수학적 기법입니다.
수학적 정의
// 선형보간 기본 공식 function lerp(start: number, end: number, t: number): number { return start + (end - start) * t; }매개변수 설명:
start: 시작값end: 끝값t: 보간 가중치 (0~1 범위)t = 0: 시작값 반환t = 1: 끝값 반환t = 0.5: 정확한 중간값 반환
시각적 표현
시작값(0) --------●-------- 끝값(1) ↑ t = 0.5일 때 중간값 = 0.5다양한 데이터 타입별 보간
1. 2D 위치 보간
function lerpPosition(fromPos: Vector2, toPos: Vector2, t: number): Vector2 { return { x: lerp(fromPos.x, toPos.x, t), y: lerp(fromPos.y, toPos.y, t) }; } // 사용 예시 const fromPosition = { x: 100, y: 200 }; const toPosition = { x: 300, y: 400 }; const interpolatedPos = lerpPosition(fromPosition, toPosition, 0.5); // 결과: { x: 200, y: 300 }2. 회전 보간
function lerpRotation(fromAngle: number, toAngle: number, t: number): number { // 각도 차이 계산 (최단 경로) let diff = toAngle - fromAngle; // 360도 이상 차이나는 경우 보정 if (diff > Math.PI) diff -= 2 * Math.PI; if (diff < -Math.PI) diff += 2 * Math.PI; return fromAngle + diff * t; } // 사용 예시 const fromRotation = 0; // 0도 const toRotation = Math.PI; // 180도 const interpolatedRotation = lerpRotation(fromRotation, toRotation, 0.5); // 결과: Math.PI / 2 (90도)3. 스케일 보간
function lerpScale(fromScale: Vector2, toScale: Vector2, t: number): Vector2 { return { x: lerp(fromScale.x, toScale.x, t), y: lerp(fromScale.y, toScale.y, t) }; } // 사용 예시 const fromScale = { x: 1.0, y: 1.0 }; const toScale = { x: 2.0, y: 0.5 }; const interpolatedScale = lerpScale(fromScale, toScale, 0.5); // 결과: { x: 1.5, y: 0.75 }4. 색상 보간
function lerpColor(fromColor: Color, toColor: Color, t: number): Color { return { r: lerp(fromColor.r, toColor.r, t), g: lerp(fromColor.g, toColor.g, t), b: lerp(fromColor.b, toColor.b, t), a: lerp(fromColor.a, toColor.a, t) }; } // 사용 예시 const fromColor = { r: 255, g: 0, b: 0, a: 1.0 }; // 빨간색 const toColor = { r: 0, g: 0, b: 255, a: 0.5 }; // 파란색(반투명) const interpolatedColor = lerpColor(fromColor, toColor, 0.5); // 결과: { r: 127.5, g: 0, b: 127.5, a: 0.75 } (보라색)
실제 구현 코드 분석
전체 믹스 설정 시스템
// 모든 애니메이션 조합에 대한 믹스 설정 private setAllMix() { const list = this.getAnimationNames(); const len = list.length; for (let i = 0; i < len; i++) { const stAnim = list[i]; for (let j = 0; j < len; j++) { const edAnim = list[j]; if (stAnim != edAnim) { this.setMix(stAnim, edAnim, 1); } } } }구현 특징:
- 모든 애니메이션 조합에 대해 1초 전환 시간 설정
- 자기 자신으로의 전환은 제외 (
stAnim != edAnim) - O(n²) 시간 복잡도로 모든 조합 처리
실제 사용 사례
// Protagonist 클래스에서의 적용 private setAllMix() { const list = this.ch.getAnimationNames(); const len = list.length; for (let i = 0; i < len; i++) { const stAnim = list[i]; for (let j = 0; j < len; j++) { const edAnim = list[j]; if (stAnim != edAnim) { this.ch.setMix(stAnim, edAnim, 0.1); // 0.1초 블렌딩 } } } }실무 적용:
- 캐릭터 애니메이션 간 0.1초 블렌딩
- 매우 빠른 전환으로 반응성 확보
- 자연스러운 캐릭터 움직임 구현
애니메이션 전환 시 블렌딩 실행
public setAnim($anim: string, $callBack?: Function): TrackEntry { try { this._anim = $anim; const trackEntry: TrackEntry = this.ani.animationState.setAnimation( 0, this._anim, this._loop ); if ($callBack) { const listener = { complete: (entry: TrackEntry) => { $callBack(entry.animation.name); this.ani.animationState.removeListener(listener); }, }; this.ani.animationState.addListener(listener); } return trackEntry; } catch { console.error(`애니메이션(${$anim}) 찾을 수 없습니다.`); return null; } }블렌딩 실행 과정:
setAnimation()호출 시 Spine 런타임이 자동으로 믹스 테이블 확인- 현재 애니메이션과 목표 애니메이션 간의 믹스 설정이 있으면 블렌딩 적용
- 설정된 시간 동안 두 애니메이션을 보간
고급 보간 기법
1. 이징(Easing) 함수
선형보간에 이징 함수를 적용하여 더 자연스러운 움직임을 구현할 수 있습니다.
// 다양한 이징 함수들 const Easing = { // 선형 (기본) linear: (t: number) => t, // 이징 인 (천천히 시작, 빠르게 끝) easeIn: (t: number) => t * t, // 이징 아웃 (빠르게 시작, 천천히 끝) easeOut: (t: number) => 1 - (1 - t) * (1 - t), // 이징 인아웃 (천천히 시작, 빠르게 중간, 천천히 끝) easeInOut: (t: number) => t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t), // 바운스 효과 bounce: (t: number) => { if (t < 1/2.75) { return 7.5625 * t * t; } else if (t < 2/2.75) { return 7.5625 * (t -= 1.5/2.75) * t + 0.75; } else if (t < 2.5/2.75) { return 7.5625 * (t -= 2.25/2.75) * t + 0.9375; } else { return 7.5625 * (t -= 2.625/2.75) * t + 0.984375; } } }; // 이징 적용된 보간 function lerpWithEasing(start: number, end: number, t: number, easing: Function): number { const easedT = easing(t); return lerp(start, end, easedT); }2. 구면 선형 보간(SLERP) - 회전용
회전의 경우 선형보간 대신 구면 선형 보간을 사용하여 더 정확한 결과를 얻을 수 있습니다.
// 쿼터니언 구면 선형 보간 function slerp(fromQuat: Quaternion, toQuat: Quaternion, t: number): Quaternion { let dot = fromQuat.x * toQuat.x + fromQuat.y * toQuat.y + fromQuat.z * toQuat.z + fromQuat.w * toQuat.w; // 최단 경로 보장 if (dot < 0) { toQuat = toQuat.negate(); dot = -dot; } let theta = Math.acos(Math.min(dot, 1)); let sinTheta = Math.sin(theta); if (sinTheta > 0.001) { let fromWeight = Math.sin((1 - t) * theta) / sinTheta; let toWeight = Math.sin(t * theta) / sinTheta; return new Quaternion( fromQuat.x * fromWeight + toQuat.x * toWeight, fromQuat.y * fromWeight + toQuat.y * toWeight, fromQuat.z * fromWeight + toQuat.z * toWeight, fromQuat.w * fromWeight + toQuat.w * toWeight ); } else { // 거의 같은 방향이면 선형 보간 return lerp(fromQuat, toQuat, t); } }3. 시간 기반 보간
실제 애니메이션에서는 시간을 기반으로 보간 가중치를 계산합니다.
// 시간 기반 보간 가중치 계산 function calculateBlendWeight(elapsedTime: number, totalBlendTime: number): number { return Math.min(elapsedTime / totalBlendTime, 1.0); } // 프레임별 블렌딩 업데이트 class AnimationBlender { private blendStartTime: number = 0; private blendDuration: number = 0; private fromAnimation: Animation; private toAnimation: Animation; update(currentTime: number) { const elapsedTime = currentTime - this.blendStartTime; const blendWeight = calculateBlendWeight(elapsedTime, this.blendDuration); // 각 본에 대해 보간 적용 for (const bone of this.skeleton.bones) { const fromTransform = this.fromAnimation.getBoneTransform(bone, currentTime); const toTransform = this.toAnimation.getBoneTransform(bone, currentTime); bone.transform = blendBoneTransforms(fromTransform, toTransform, blendWeight); } // 블렌딩 완료 체크 if (blendWeight >= 1.0) { this.completeBlending(); } } }
성능 최적화 전략
1. 룩업 테이블 사용
자주 사용되는 이징 함수의 값을 미리 계산하여 성능을 향상시킬 수 있습니다.
// 사전 계산된 이징 값들 const EASING_TABLE = new Float32Array(1000); for (let i = 0; i < 1000; i++) { const t = i / 999; EASING_TABLE[i] = Easing.easeInOut(t); } // 빠른 이징 계산 function fastEasing(t: number): number { const index = Math.floor(t * 999); return EASING_TABLE[Math.min(index, 999)]; }2. 벡터화 연산
SIMD를 활용한 벡터 연산으로 성능을 향상시킬 수 있습니다.
// SIMD를 활용한 벡터 보간 (최신 브라우저) function vectorizedLerp(from: Float32Array, to: Float32Array, t: number): Float32Array { const result = new Float32Array(from.length); for (let i = 0; i < from.length; i++) { result[i] = lerp(from[i], to[i], t); } return result; }3. 불필요한 블렌딩 방지
같은 애니메이션으로의 전환은 블렌딩을 수행하지 않도록 최적화합니다.
// 불필요한 블렌딩 방지 if (stAnim != edAnim) { this.setMix(stAnim, edAnim, 1); }4. 메모리 풀링
자주 사용되는 벡터 객체를 재사용하여 가비지 컬렉션을 줄입니다.
class Vector2Pool { private pool: Vector2[] = []; get(): Vector2 { return this.pool.pop() || new Vector2(); } release(vector: Vector2) { this.pool.push(vector); } }
실무 적용 사례
1. 캐릭터 애니메이션 시스템
// 캐릭터 상태별 애니메이션 전환 class CharacterAnimationController { private spineX: SpineX; constructor(spineX: SpineX) { this.spineX = spineX; this.setupAnimationMixes(); } private setupAnimationMixes() { // 기본 상태 간 전환 this.spineX.setMix('idle', 'walk', 0.2); this.spineX.setMix('walk', 'run', 0.15); this.spineX.setMix('run', 'jump', 0.1); // 액션 상태 전환 this.spineX.setMix('idle', 'attack', 0.05); this.spineX.setMix('walk', 'attack', 0.05); this.spineX.setMix('run', 'attack', 0.05); // 복귀 전환 this.spineX.setMix('attack', 'idle', 0.3); this.spineX.setMix('jump', 'idle', 0.4); } public playIdle() { this.spineX.setAnim('idle', true); } public playWalk() { this.spineX.setAnim('walk', true); } public playAttack() { this.spineX.setAnim('attack', false, () => { this.playIdle(); // 공격 완료 후 기본 상태로 복귀 }); } }2. UI 애니메이션 시스템
// UI 요소의 부드러운 전환 class UIAnimationController { private element: HTMLElement; constructor(element: HTMLElement) { this.element = element; } public fadeIn(duration: number = 300) { this.animateProperty('opacity', 0, 1, duration, Easing.easeOut); } public fadeOut(duration: number = 300) { this.animateProperty('opacity', 1, 0, duration, Easing.easeIn); } public slideIn(direction: 'left' | 'right' | 'top' | 'bottom', duration: number = 300) { const startPos = this.getSlideStartPosition(direction); const endPos = { x: 0, y: 0 }; this.animatePosition(startPos, endPos, duration, Easing.easeOut); } private animateProperty(property: string, from: number, to: number, duration: number, easing: Function) { const startTime = performance.now(); const animate = (currentTime: number) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const easedProgress = easing(progress); const currentValue = lerp(from, to, easedProgress); this.element.style[property] = currentValue.toString(); if (progress < 1) { requestAnimationFrame(animate); } }; requestAnimationFrame(animate); } }3. 카메라 시스템
// 카메라 부드러운 이동 class SmoothCamera { private currentPosition: Vector3; private targetPosition: Vector3; private currentRotation: Vector3; private targetRotation: Vector3; private blendTime: number = 0.5; private currentBlendTime: number = 0; public moveTo(position: Vector3, rotation: Vector3, duration: number = 0.5) { this.currentPosition = this.currentPosition || position.clone(); this.currentRotation = this.currentRotation || rotation.clone(); this.targetPosition = position; this.targetRotation = rotation; this.blendTime = duration; this.currentBlendTime = 0; } public update(deltaTime: number) { if (this.currentBlendTime < this.blendTime) { this.currentBlendTime += deltaTime; const progress = this.currentBlendTime / this.blendTime; const easedProgress = Easing.easeInOut(progress); // 위치 보간 this.currentPosition.x = lerp(this.currentPosition.x, this.targetPosition.x, easedProgress); this.currentPosition.y = lerp(this.currentPosition.y, this.targetPosition.y, easedProgress); this.currentPosition.z = lerp(this.currentPosition.z, this.targetPosition.z, easedProgress); // 회전 보간 (SLERP 사용 권장) this.currentRotation.x = lerpRotation(this.currentRotation.x, this.targetRotation.x, easedProgress); this.currentRotation.y = lerpRotation(this.currentRotation.y, this.targetRotation.y, easedProgress); this.currentRotation.z = lerpRotation(this.currentRotation.z, this.targetRotation.z, easedProgress); } } }
결론
Spine 애니메이션의 블렌딩 시스템과 선형보간은 게임 개발에서 자연스러운 애니메이션 전환을 구현하는 핵심 기술입니다.
주요 포인트
- Spine의 내장 블렌딩 시스템 활용: 복잡한 수학적 계산 없이도 고품질 블렌딩 구현
- 선형보간의 수학적 이해: 다양한 데이터 타입에 대한 보간 기법 숙지
- 성능 최적화: 룩업 테이블, 벡터화 연산, 메모리 풀링 등 활용
- 실무 적용: 캐릭터 애니메이션, UI, 카메라 등 다양한 분야에서 활용
개발 시 주의사항
- 적절한 블렌딩 시간 설정: 너무 길면 느려 보이고, 너무 짧으면 효과가 미미
- 이징 함수 선택: 상황에 맞는 이징 함수로 자연스러운 움직임 구현
- 성능 고려: 복잡한 애니메이션의 경우 성능 최적화 기법 적용
- 메모리 관리: 벡터 객체 재사용으로 가비지 컬렉션 최소화
이러한 기술을 잘 활용하면 사용자에게 더욱 매끄럽고 자연스러운 애니메이션 경험을 제공할 수 있습니다.
'typescript' 카테고리의 다른 글
Phaser Tween Chain 가이드 (0) 2025.02.15 JavaScript/TypeScript의 async 이해하기 (2) 2024.12.11