ABOUT ME

-

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

    목차

    1. 개요
    2. Spine 블렌딩 시스템 이해
    3. 선형보간(LERP)의 수학적 원리
    4. 실제 구현 코드 분석
    5. 고급 보간 기법
    6. 성능 최적화 전략
    7. 실무 적용 사례
    8. 결론

    개요

    게임 개발에서 캐릭터 애니메이션의 자연스러운 전환은 사용자 경험에 큰 영향을 미칩니다. 갑작스러운 애니메이션 전환은 부자연스러운 느낌을 주지만, 적절한 블렌딩을 통해 부드럽고 자연스러운 전환을 구현할 수 있습니다.

    이 글에서는 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;
        }
    }

    블렌딩 실행 과정:

    1. setAnimation() 호출 시 Spine 런타임이 자동으로 믹스 테이블 확인
    2. 현재 애니메이션과 목표 애니메이션 간의 믹스 설정이 있으면 블렌딩 적용
    3. 설정된 시간 동안 두 애니메이션을 보간

    고급 보간 기법

    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 애니메이션의 블렌딩 시스템과 선형보간은 게임 개발에서 자연스러운 애니메이션 전환을 구현하는 핵심 기술입니다.

    주요 포인트

    1. Spine의 내장 블렌딩 시스템 활용: 복잡한 수학적 계산 없이도 고품질 블렌딩 구현
    2. 선형보간의 수학적 이해: 다양한 데이터 타입에 대한 보간 기법 숙지
    3. 성능 최적화: 룩업 테이블, 벡터화 연산, 메모리 풀링 등 활용
    4. 실무 적용: 캐릭터 애니메이션, UI, 카메라 등 다양한 분야에서 활용

    개발 시 주의사항

    • 적절한 블렌딩 시간 설정: 너무 길면 느려 보이고, 너무 짧으면 효과가 미미
    • 이징 함수 선택: 상황에 맞는 이징 함수로 자연스러운 움직임 구현
    • 성능 고려: 복잡한 애니메이션의 경우 성능 최적화 기법 적용
    • 메모리 관리: 벡터 객체 재사용으로 가비지 컬렉션 최소화

    이러한 기술을 잘 활용하면 사용자에게 더욱 매끄럽고 자연스러운 애니메이션 경험을 제공할 수 있습니다.

    'typescript' 카테고리의 다른 글

    Phaser Tween Chain 가이드  (0) 2025.02.15
    JavaScript/TypeScript의 async 이해하기  (2) 2024.12.11
Designed by Tistory.