유니티 스파인 적용 및 사용법

Spine이란, 2D를 사용하는 프로젝트에서 정말 많이 사용되는 툴로 쉬운 제작, 높은 퀄리티 및 생산성을 가진다.

스파인에 대한 장점은 해당 사이트에서 쉽게 정리된 글로 확인할 수 있다.

스파인 장점

전 프로젝트에서는 스프라이트 애니메이션으로 제작을 했지만 이번 프로젝트는 스파인을 사용하기로 했다..!

Runtime Spine Import

스파인 캐릭터를 유니티에서 원활하게 사용하기 위해선 스파인 공식홈페이지에서 프로그램을 다운받아야 한다.

다운로드 링크

맨위의 spine-unity(unity package)를 받아주면 된다. (패키지로 주기 때문에 설치가 간단하다.)

아래 URP(Universal Render Pipeline)까지 받고 싶다면 클릭해서 설치하면 된다.

package를 import하게 되면 spine에 관련된 패키지와 Spine Examples가 다운이 된다.

Spine Examples를 뜯어보면서 공부하는게 가장 효율이 좋았다.

spine에서 유니티

아트파트나 본인이 스파인에서 작업한 결과물을 유니티로 옮기고 싶다면 spine을 유니티 export형식에 맞춰서 빼준다음 unity로 옮기게 되면 자동으로 연동이 된다.

atlas.txt, json, png총 3가지 확장자 파일을 옮기면 된다.

name_SkeletonData파일로 만들어지게 된다.

해당 파일을 씬으로 옮기면 실제로 동작하는 모습을 확인할 수 있고 인스펙터 창에서 애니메이션을 바꿔가며 확인할 수 있다.

이미지

Animation Name 필드를 선택하면 된다.

유니티에서 애니메이션 제어 (1)

해당 캐릭터를 게임과 같이 동작시킬려면 애니메이션 동작에 관한 처리를 코드르 작성해야하는데 이는 unity Aniamtor컴포넌트를 사용해서 다루는게 아니다.

SkeletonAnimation컴포넌트를 통해서 관리할 수 있다.

따라서 코드에서 제어하기 위해서는 해당 컴포넌트 레퍼런스에 접근하여 state에 내장된 함수로 제어한다.

이미지

실제 spine Examples 예제 중 하나

using System.Collections;
using UnityEngine;

namespace Spine.Unity.Examples {
	public class SpineBlinkPlayer : MonoBehaviour {
		const int BlinkTrack = 1; // 해당 애니메이션 재생 트랙

		public AnimationReferenceAsset blinkAnimation; // 재생할 애니메이션 클립
		public float minimumDelay = 0.15f;
		public float maximumDelay = 3f;

		IEnumerator Start () {
			var skeletonAnimation = GetComponent<SkeletonAnimation>(); if (skeletonAnimation == null) yield break;  
      // 컴포넌트 할당 그리고 예외처리(방어적 프로그래밍 필수)
			while (true) {
				skeletonAnimation.AnimationState.SetAnimation(SpineBlinkPlayer.BlinkTrack, blinkAnimation, false);
        // 해당 애니메이션 재생 순서대로 트랙, 재생할 애니메이션 클립, 반복여부

				yield return new WaitForSeconds(Random.Range(minimumDelay, maximumDelay));
			}
		}

	}
}

위 처럼 사용할 수 있다 내부 기능도 매우 다양하고 트랙을 설정할 수 있어서 애니메이션을 섞어서 사용가능하다.

해당 예제를 확인할려면 Spine Examples -> Getting Started -> The Spine GameObject이다.

스파인의 정말 좋은 기능은 동시에 애니메이션을 재생할 수 있다는 점이다.

위의 예제를 살짝 변형해서 Idle 동작을 합칠 수 있다.

namespace Spine.Unity.Examples {
	public class SpineBlinkPlayer : MonoBehaviour {
		const int BlinkTrack = 1;

		public AnimationReferenceAsset blinkAnimation;
		public AnimationReferenceAsset IdleAnimation;  // Idle 애니메이션 추가
		public float minimumDelay = 0.15f;
		public float maximumDelay = 3f;

		IEnumerator Start () {
			var skeletonAnimation = GetComponent<SkeletonAnimation>(); if (skeletonAnimation == null) yield break;

			skeletonAnimation.AnimationState.SetAnimation(0, IdleAnimation, true); // 0번 트랙에 루프로 재생
			while (true) {
				skeletonAnimation.AnimationState.SetAnimation(1, blinkAnimation, false);
				yield return new WaitForSeconds(Random.Range(minimumDelay, maximumDelay));
			}
		}
	}
}

이미지


기본적인 동작방식을 정리해보자

  1. AnimationReferenceAsset형식을 통해서 애니메이션클립에 접근할 수 있다.
  2. 애니메이션제어를 하기 위해서 SkeletonAnimation컴포넌트에 접근해야 한다.
  3. AnimationState는 현재 애니메이션 설정을 할 수 있다.

보기 처럼 스파인에 대한 정보가 그렇게 많지 않기 때문에 스파인에서 제공한 Example을 보고 학습하는게 도움이 된다.

유니티에서 애니메이션 제어 (2)

앞에서 AnimationReferenceAsset형식을 통해 애니메이션을 에디터상에서 드래그로 적용할 수 있다고 했지만 조금 더 유용한 방법이 있다.

애니메이션이 100개가 넘어간다고 했을 때 전부 드래그나 직접 클릭하여 하나씩 넣기에는 무리가 있다.

spine에서 제공하는 애트리뷰트로 [SpineAnimation]이다.

[SpineAnimation]
public string runAnimationName;

위 형식으로 스크립트를 등록해두면 해당 모델에 등록되어 있는 애니메이션을 선택할 수 있도록 드롭다운 메뉴로 제공된다.

이미지

앞에서 애니메이션을 재생하고 싶을 때 skeletonAnimation.AnimationState.SetAnimation함수를 사용했는데 AnimationState또한 클래스이기 때문에 변수에 연결하여 사용이 가능하다.

또한 해당 모델에 접근하기 위해선 skeleton에 접근해야 한다.

// Spine.AnimationState and Spine.Skeleton are not Unity-serialized objects. You will not see them as fields in the inspector.
public Spine.AnimationState spineAnimationState;
public Spine.Skeleton skeleton;

void Start () {
  skeletonAnimation = GetComponent<SkeletonAnimation>();
  spineAnimationState = skeletonAnimation.AnimationState;
  skeleton = skeletonAnimation.Skeleton;
}

접근 방식은 위 처럼 변수에 담아두고 사용하면 되지만 serialized objects오브젝트가 아니기 때문에 인스펙터창에 뜨지 않는다..!

SerializeField를 선언해도 뜨지 않는다.

해당 클래스나 메서드 등에 대해 자세하게 알고 싶다면 spine 공식문서를 참고..!

스파인 API 공식 문서
스파인 런타임 문서

유니티에서 애니메이션 제어 (3)

스파인의 장점 중 하나인? event제어이다.

스파인 제작 시 특정한 애니메이션에 이벤트를 미리 걸어 둘 수 있고 프로그래머는 해당 이벤트를 호출 받아서 동작할 수 있다.

쉽게 말해서 걷는 애니메이션에서 땅에 발이 닫는 순간 소리를 재생하게 하는 이벤트를 이름만 걸어두면 프로그래머는 해당 이벤트를 이용하여 제작이 가능하다.

이러한 기능을 spineEvent라고 한다.

사용하기 간편한 attribute를 사용

[SpineEvent]
public string eventName;

마찬가지로 인스펙터 창에서 드롭다운으로 해당 애니메이션 이벤트를 선택할 수 있다.

스파인 이벤트는 보라색 말풍선 모양이니 기억할 것

미리 아트파트와 이름을 맞추는게 좋아보인다..!

이미지

이름만 연결 시켜 놓는 것이기 때문에 실제 레퍼런스를 할당해야 하는데 이러한 기능까지 미리 메서드로 구현되어 있다.

EventData eventData;
// 이벤트 데이터 클래스(내부는 가볍다)

eventData = skeletonAnimation.Skeleton.Data.FindEvent(eventName);
// 해당 문자열과 해당 모델에 있는 이벤트를 foreach문으로 탐색한다.  

이제 해당 이벤트를 연결까지 했으니 특정 이벤트에만 발생하게 만들면 된다.

skeletonAnimation의 클래스 프로퍼티 AnimationState의 이벤트 메서드 event를 사용하여 메서드를 등록하고 해당 메서드의 파라미터로 주어진 이벤트 값과 해당 이벤트를 비교하여 제어하면 된다..

// 이벤트 메서드로 등록
skeletonAnimation.AnimationState.Event += HandleAnimationStateEvent;

// 이벤트가 발생할 때 마다 호출
private void HandleAnimationStateEvent (TrackEntry trackEntry, Event e) {
  bool eventMatch = (eventData == e.Data); 
  if (eventMatch) {
    Play();
  }
}

같은 맥락으로 애니메이션이 실행될 때 종료될 때 호출되는 콜백 메서드인 start, end, complete가 있다.

complete는 loop의 경우 애니메이션 종료 시

이미지

자세한 건 아래 링크를 통해서 보는게 좋다..!

spine event 공식 문서

좀 더 다양한 기능들을 정리하고 싶었는데 가능한 기능은 어마어마하게 많은데 정보는 적고.. 전부 다 공부하는 것 보다 직접 만들다가 부족한 부분을 채우는 방식이 현명해 보인다..!

spine을 사용하다 좋은 기능이나 필요한 정보를 이어서 포스팅하겠다..!

++ 08/15 추가 포스팅

SkeletonAnimationHandler

위에서 다룬 다양한 제어방법을 한가지 컴포넌트로 만들어서 해당 메서드를 다양하게 활용할 수 있게 만들었다.

spine starting에서 참고하였다.

public class SkeletonAnimationHandler : MonoBehaviour
{
    public Spine.Animation TargetAnimation { get; private set; }

    private SkeletonAnimation _skeletonAnimation;

    [SerializeField] private List<StateNameToAnimationReference> _statesAndAnimation = new List<StateNameToAnimationReference>();
    [SerializeField] private List<AnimationTransition> _transitions = new List<AnimationTransition>();

    [System.Serializable]
    public class StateNameToAnimationReference
    {
        //public string stateName;
        //public @string animation;
        //public Spine.Animation animation;
        [SpineAnimation] public string stateName;
        public Spine.Animation animation;
    }

    [System.Serializable]
    public class AnimationTransition
    {
        [SpineAnimation] public string fromeName;
        public Spine.Animation from;
        [SpineAnimation] public string toName;
        public Spine.Animation to;
        [SpineAnimation] public string transitionName;
        public Spine.Animation transition;
    }

    private void Awake ()
    {
        _skeletonAnimation = GetComponent<SkeletonAnimation>();

        foreach (var entry in _statesAndAnimation)
        {
            
            SkeletonData skeletonData = _skeletonAnimation.skeletonDataAsset.GetSkeletonData(true);
            entry.animation = skeletonData != null ? skeletonData.FindAnimation(entry.stateName) : null;
            //this.animation = skeletonData != null ? skeletonData.FindAnimation(animationName) : null;
        }

        foreach (var entry in _transitions)
        {
            SkeletonData skeletonData = _skeletonAnimation.skeletonDataAsset.GetSkeletonData(true);
            
            entry.from = skeletonData != null ? skeletonData.FindAnimation(entry.fromeName) : null;
            entry.to = skeletonData != null ? skeletonData.FindAnimation(entry.toName) : null;
            entry.transition = skeletonData != null ? skeletonData.FindAnimation(entry.transitionName) : null;
        }
    }

    /// <summary>
    /// 2D 뒤집기 메서드
    /// </summary>
    /// <param name="horizontal"></param>
    public void SetFlip(float horizontal)
    {
        if (horizontal != 0)
        {
            _skeletonAnimation.skeleton.ScaleX = horizontal > 0 ? 1f : -1f;
        }
    }

    public void PlayAnimationForState(string stateShortName, int layerIndex)
    {
        PlayAnimationForState(StringToHash(stateShortName), layerIndex);
    }

    /// <summary>
    /// PlayAnimationForState Overloading 해당 애니메이션을 실행
    /// </summary>
    /// <param name="stateShortName">실행하고자 하는 애니메이션 이름</param>
    /// <param name="layerIndex">트랙/레이어 번호</param>
    public void PlayAnimationForState(int stateShortName, int layerIndex)
    {
        var foundAnimation = GetAnimationForState(stateShortName);
        if (foundAnimation == null)
            return;

        PlayNewAnimation(foundAnimation, layerIndex);
    }

    public Spine.Animation GetAnimationForState(string stateShortName)
    {
        return GetAnimationForState(StringToHash(stateShortName));
    }
    
    /// <summary>
    /// GetAnimationForState Overloading 해당 애니메이션을 반환(없다면 null)
    /// </summary>
    /// <param name="stateShortName">찾고자 하는 애니메이션 이름(정수로 들어옴)</param>
    /// <returns>해당 애니메이션</returns>
    public Spine.Animation GetAnimationForState(int stateShortName)
    {
        var foundState = _statesAndAnimation.Find(entry => StringToHash(entry.stateName) == stateShortName);
        return ((foundState == null) ? null : foundState.animation);
    }

    /// <summary>
    /// 애니메이션 재생 메서드
    /// 현재 진행중인 애니메이션이 없다면 || 전환 애니메이션이 없다면 바로 애니메이션 전환
    /// 있다면 전환 애니메이션 우선 재생 후 재생
    /// </summary>
    /// <param name="target"></param>
    /// <param name="layerIndex"></param>
    public void PlayNewAnimation(Spine.Animation target, int layerIndex)
    {
        Spine.Animation transition = null;
        Spine.Animation current = null;

        current = GetCurrentAnimation(layerIndex);
        if (current != null)
            transition = TryGetTransition(current, target);

        if (transition != null)
        {
            _skeletonAnimation.AnimationState.SetAnimation(layerIndex, transition, false);
            _skeletonAnimation.AnimationState.AddAnimation(layerIndex, target, true, 0f);
        }
        else
        {
            _skeletonAnimation.AnimationState.SetAnimation(layerIndex, target, true);
        }

        this.TargetAnimation = target;
    }

    /// <summary>
    /// OneShot 애니메이션 메서드
    /// </summary>
    /// <param name="oneShot">한번 재생할 애니메이션 클립</param>
    /// <param name="layerIndex">null</param>
    public void PlayOneShot(Spine.Animation oneShot, int layerIndex)
    {
        var state = _skeletonAnimation.AnimationState;
        state.SetAnimation(0, oneShot, false);

        var transition = TryGetTransition(oneShot, TargetAnimation);
        if (transition != null)
        {
            state.AddAnimation(0, transition, false, 0f);
        }
        
        // delay fix..!
        state.AddAnimation(0, TargetAnimation, true, oneShot.Duration);
    }

    /// <summary>
    /// 현재 애니메이션에서 다음 애니메이션으로 전환될 때 전환 애니메이션이 있는지 판단
    /// </summary>
    /// <param name="from">현재 애니메이션</param>
    /// <param name="to">다음 애니메이션</param>
    /// <returns>없다면 null 있다면 전환애니메이션(ex)ldel-to-jump)</returns>
    private Spine.Animation TryGetTransition(Spine.Animation from, Spine.Animation to)
    {
        foreach (var transition in _transitions)
        {
            if (transition.from == from && transition.to == to)
            {
                return transition.transition;
            }
        }

        return null;
    }

    /// <summary>
    /// 현재 애니메이션을 불러오는 메서드
    /// </summary>
    /// <param name="layout">해당 애니메이션 트랙/layout</param>
    /// <returns>Spine.Animation</returns>
    private Spine.Animation GetCurrentAnimation(int layout)
    {
        var currentTrackEntry = _skeletonAnimation.AnimationState.GetCurrent(layout);
        return (currentTrackEntry != null) ? currentTrackEntry.Animation : null;
    }

    /// <summary>
    /// 애니메이션 문자열을 해쉬값으로 반환
    /// </summary>
    /// <param name="str"></param>
    /// <returns></returns>
    private int StringToHash(string str)
    {
        return Animator.StringToHash(str);
    }
    
}

위 처럼 cs를 만들어 놓으면 unity animation과 같이 duration, delay, oneshot등 다양한 기능들을 비슷하게 사용할 수 있다.

spine은 mix의 기능을 사용할 수 있기 때문에 함수를 하나 추가하여 mix기능의 함수를 넣어도 되고 다양하게 활용이 가능하다.

왜 기능들을 직접 만들어 봐야하는지 이해가 잘된 예제

  1. 애니메이션 플레이함수 PlayNewAnimation
    1. 함수에 진입하여 연결되는 애니메이션인지 체크하고 아니라면 해당 애니메이션을 set으로 강제 재생(해당 트랙으로)
    2. 주로 이동관련 애니메이션?
  2. 애니메이션 oneshot함수 PlayOneShot
    1. 우선순위를 무시하고 해당 0번 트랙에 set으로 호출 후 prev 즉, 전애니메이션 add로 뒤로 밀어준다.
    2. 공격이나 피격등 any state에 해당된다.

나머지는 해당 함수에서 사용하는 유틸함수들이다.

생각보다 훨씬 spine내부에 사용가능한 파리미터나 함수가 많아서 다양한 연출이 가능하다.

조금 더 뜯어봐야겠다..!

아아!

++ 11/19 포스팅

Spine Advanced

태그: ,

카테고리:

업데이트:

댓글남기기