← 모든 글

포트폴리오 제작기 8 - Animator State 프리뷰와 이벤트 도구 만들기

Unity Animator State 인스펙터에서 애니메이션을 바로 확인하는 프리뷰 기능과, 스테이트 기준으로 이벤트를 정의하고 실행하는 도구를 만든 과정을 정리했습니다.

포트폴리오 게임 제작기 PortfolioUnityEditorAnimatorAnimationEvent

애니메이터 작업 흐름이 너무 불편했다

이번에는 게임 플레이 기능 자체보다 작업 흐름을 편하게 만드는 도구를 만들었다. Unity의 Animator State를 선택했을 때, 해당 스테이트에 연결된 애니메이션을 인스펙터에서 바로 미리 볼 수 있는 기능이다.

애니메이션을 붙이다 보면 Animator Controller 안에서 여러 스테이트를 계속 오가게 된다. Idle, Walk, Run, Attack 같은 상태를 만들고 각각에 Animation Clip을 연결한 뒤, 실제로 어떤 동작이 들어가 있는지 확인하려면 Project 창에서 클립을 찾아보거나 씬에서 재생해봐야 했다.

스테이트를 보고 있는데 정작 그 스테이트의 애니메이션은 바로 확인하기 어렵다는 점이 너무 불편했다. 특히 이름이 비슷한 클립이 늘어나면 "이 스테이트에 지금 어떤 모션이 붙어 있었지?"를 확인하는 데 생각보다 많은 시간이 들었다.

그래서 인터넷을 뒤져보다가 Animator State와 Animation Preview를 다루는 좋은 자료를 찾았고, 그 내용을 참고해서 직접 스테이트 프리뷰 기능을 만들어보기로 했다.

Animator State 작업 흐름 비교

기존에는 상태를 보고, 클립을 찾고, 프리뷰나 이벤트 설정을 다시 확인하는 식으로 흐름이 자주 끊겼다. 이번에 만든 도구의 목적은 이 과정을 최대한 Animator State 인스펙터 안으로 모으는 것이었다.

애니메이터 스테이트에서 애니메이션을 보자

스테이트 프리뷰 기능이 없는 스테이트의 인스펙터

위 화면처럼 기본 Animator State 인스펙터에서는 Motion 정보는 보이지만, 그 자리에서 애니메이션의 움직임을 바로 훑어보기는 어렵다. 작은 차이처럼 보이지만, 애니메이션을 반복해서 연결하고 확인하는 단계에서는 이 이동 비용이 꽤 크게 느껴졌다.

처음부터 거창한 애니메이션 편집기를 만들 생각은 없었다. 목표는 단순했다. Animator State를 선택했을 때, 인스펙터 안에서 연결된 애니메이션을 바로 확인할 수 있으면 된다.

원하는 흐름은 이런 식이었다.

핵심은 작업자가 Animator 창, Project 창, Preview 창을 계속 오가지 않아도 되게 만드는 것이었다.

Animator State 인스펙터에 미리보기 창 띄우기

Unity Editor에는 기본 인스펙터를 확장할 수 있는 기능이 있다. 일반 컴포넌트처럼 MonoBehaviour만 커스텀할 수 있는 것이 아니라, 에디터 내부 객체에 대해서도 Custom Editor를 작성할 수 있다.

이번 작업에서는 Animator State를 선택했을 때 표시되는 인스펙터에 프리뷰 영역을 추가하는 방향으로 접근했다. State에 연결된 Motion을 가져오고, 그 Motion이 Animation Clip이라면 프리뷰로 보여주는 식이다.

기존 인스펙터를 크게 바꾸기보다는, 아래쪽에 Animation Clip 프리뷰 기능을 덧붙이는 형태가 가장 자연스럽다고 판단했다.

실제로 Animator State 프리뷰를 붙이는 코드는 다음과 같다.

using System.Reflection;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;

namespace Invasion
{
    [CustomPreview(typeof(AnimatorState))]
    public class AnimatorStateObjectPreview : ObjectPreview
    {
        static FieldInfo _cachedAvatarPreviewField;
        static FieldInfo _cachedTimeControlField;
        static FieldInfo _cachedStopTimeField;
        static FieldInfo _cachedCurrentTimeField;

        Editor _preview;
        EntityId _animationClipId;

        public override void Initialize(Object[] targets)
        {
            base.Initialize(targets);

            if (targets.Length > 1 || Application.isPlaying)
                return;

            SourceAnimationClipEditorFields();

            AnimationClip clip = GetAnimationClip(target as AnimatorState);
            if (clip != null)
            {
                _preview = Editor.CreateEditor(clip);
                _animationClipId = clip.GetEntityId();
            }
        }

        public override void Cleanup()
        {
            base.Cleanup();
            CleanUpPreviewEditor();
        }

        public override bool HasPreviewGUI()
        {
            return _preview?.HasPreviewGUI() ?? false;
        }

        public override void OnInteractivePreviewGUI(Rect r, GUIStyle background)
        {
            base.OnInteractivePreviewGUI(r, background);

            AnimationClip currentClip = GetAnimationClip(target as AnimatorState);
            if (currentClip != null && currentClip.GetEntityId() != _animationClipId)
            {
                CleanUpPreviewEditor();
                _preview = Editor.CreateEditor(currentClip);
                _animationClipId = currentClip.GetEntityId();
                return;
            }

            if (_preview != null)
            {
                UpdateAnimationClipEditor(_preview, currentClip);
                _preview.OnInteractivePreviewGUI(r, background);
            }
        }

        public override void OnPreviewGUI(Rect r, GUIStyle background)
        {
            base.OnPreviewGUI(r, background);
        }

        AnimationClip GetAnimationClip(AnimatorState state)
        {
            return state?.motion as AnimationClip;
        }

        void CleanUpPreviewEditor()
        {
            if (_preview != null)
            {
                Object.DestroyImmediate(_preview);
                _preview = null;
                _animationClipId = default;
            }
        }

        static void SourceAnimationClipEditorFields()
        {
            if (_cachedAvatarPreviewField != null && _cachedTimeControlField != null && _cachedStopTimeField != null && _cachedCurrentTimeField != null)
                return;

            _cachedAvatarPreviewField = System.Type.GetType("UnityEditor.AnimationClipEditor, UnityEditor")?.GetField("m_AvatarPreview", BindingFlags.NonPublic | BindingFlags.Instance);
            _cachedTimeControlField = System.Type.GetType("UnityEditor.AvatarPreview, UnityEditor")?.GetField("timeControl", BindingFlags.Public | BindingFlags.Instance);
            _cachedStopTimeField = System.Type.GetType("UnityEditor.TimeControl, UnityEditor")?.GetField("stopTime", BindingFlags.Public | BindingFlags.Instance);
            _cachedCurrentTimeField = System.Type.GetType("UnityEditor.TimeControl, UnityEditor")?.GetField("currentTime", BindingFlags.Public | BindingFlags.Instance);
        }

        void UpdateAnimationClipEditor(Editor editor, AnimationClip clip)
        {
            if (_cachedAvatarPreviewField == null || _cachedTimeControlField == null || _cachedStopTimeField == null || _cachedCurrentTimeField == null)
                return;

            var avatarPreview = _cachedAvatarPreviewField.GetValue(editor);
            var timeControl = _cachedTimeControlField.GetValue(avatarPreview);

            _cachedStopTimeField.SetValue(timeControl, clip.length);

            if (clip.length > 0f)
            {
                float currentTime = (float)_cachedCurrentTimeField.GetValue(timeControl);
                float normalizedTime = currentTime / clip.length;
                AnimatorPreviewTimelineBridge.Publish(clip, normalizedTime);
            }
        }
    }
}

핵심은 AnimatorStateCustomPreview를 붙이고, State에 연결된 AnimationClip의 기본 Preview Editor를 재사용하는 것이다. 클립이 바뀌면 기존 Preview Editor를 정리한 뒤 새 클립으로 다시 만들었다. Unity 내부의 AnimationClipEditor, AvatarPreview, TimeControl 값은 public API로 직접 접근하기 어려워서 Reflection으로 필요한 필드만 가져왔다.

여기서 Reflection을 사용한 이유는 단순하다. 유니티가 해당 인터페이스를 제공해주지 않기 때문이다 ㅡㅡ. 프리뷰 재생 시간이나 내부 TimeControl에 접근할 수 있는 공식적인 통로가 있었다면 굳이 이런 방식으로 내부 필드를 꺼내올 필요는 없었을 것이다.

AnimatorPreviewTimelineBridge.Publish는 현재 프리뷰 재생 위치를 다른 에디터 UI와 공유하기 위한 연결 지점이다. 프리뷰 창에서 움직이는 시간이 이벤트 표시나 타임라인 UI와 맞아야 하기 때문에, 현재 시간을 normalized time으로 바꿔 넘기도록 했다.

프리뷰가 붙은 결과

기능을 붙이고 나니 Animator State를 선택했을 때 바로 애니메이션을 확인할 수 있게 되었다.

스테이트 프리뷰 기능이 있는 스테이트의 인스펙터

이제 State 인스펙터 안에서 Motion 정보와 프리뷰가 함께 보인다. 스테이트를 클릭한 상태 그대로 애니메이션의 전체적인 움직임을 확인할 수 있으니, 클립을 찾아다니는 시간이 줄었다.

도구를 만들 때 조심한 부분

에디터 확장은 만들기 시작하면 욕심이 쉽게 커진다. 재생 버튼, 정지 버튼, 프레임 이동, 속도 조절, 루프 설정, 모델 선택 같은 기능을 계속 붙이고 싶어진다.

하지만 이번에는 포트폴리오 게임 제작을 돕는 작은 도구가 목적이었다. 그래서 처음 버전에서는 반드시 필요한 기능만 남기려 했다. Animator State를 선택했을 때 프리뷰를 보여주고, 현재 연결된 애니메이션을 빠르게 확인할 수 있으면 충분하다고 봤다.

또 하나 신경 쓴 부분은 기존 작업 흐름을 방해하지 않는 것이었다. 프리뷰 기능이 있다고 해서 기본 인스펙터를 읽기 어려워지면 안 된다. 그래서 프리뷰는 보조 영역으로 두고, State의 주요 설정을 확인하는 흐름은 그대로 유지하는 쪽으로 잡았다.

스테이트에서 이벤트 발생시키자!

프리뷰를 만들고 나니 스테이트 인스펙터에서 한 가지를 더 처리하고 싶어졌다. 애니메이션의 특정 시점에 게임 이벤트를 발생시키는 기능이다.

기존에는 이벤트를 심으려면 Animation Clip에 직접 접근해야 했다. Animator State에서 어떤 동작을 확인하다가 이벤트를 추가하려면 다시 해당 Clip을 찾아가고, 이벤트 위치를 조정한 뒤, 다시 State 쪽으로 돌아와 확인해야 했다. 이 과정을 계속 왔다 갔다 하는 부분이 너무 번거로웠다.

그래서 이벤트도 State 기준으로 관리할 수 있게 만들기로 했다. Unity에는 Animation Event가 있지만, 이번 프로젝트에서는 네트워크 흐름과 연결되는 이벤트를 조금 더 명확하게 관리하고 싶었다. 그래서 Animator State에 StateMachineBehaviour를 붙이고, 스테이트 진입, 업데이트 중 특정 normalized time, 스테이트 종료 시점에 이벤트를 발생시키는 작은 프레임워크를 만들었다.

Animator State 이벤트 처리 흐름

이벤트 흐름은 크게 세 단계로 나눴다. State에서는 언제 이벤트가 나가야 하는지만 정의하고, Event Raiser는 그 시점에 이벤트 이름과 파라미터를 Listener로 넘긴다. Listener는 네트워크 Tick 안에서 큐를 처리하고, 등록된 콜백을 실행한다.

Animator State에서 이벤트를 발생시키기 위한 Event Raiser

구조는 단순하게 잡았다. 각 이벤트는 실행 타이밍과 normalized time, 이벤트 이름, 파라미터를 가진다. 스테이트가 시작되면 OnEnter 이벤트를 실행하고, 재생 중에는 현재 normalized time이 이벤트 시점을 지나면 OnUpdate 이벤트를 실행한다. 스테이트가 끝날 때는 OnExit 이벤트를 실행한다.

여기서 WellknownEventName은 enum 값이다. 필요한 이벤트 이름을 이 enum에 정의해두고 사용하면 된다. 문자열 기반 이벤트처럼 일반적인 방식으로 만들 수도 있었지만, 이번 프로젝트에는 사용자 정의 콘텐츠가 없고, 게임을 만들면서 필요한 애니메이션 이벤트들을 내가 직접 잘 정의할 수 있다고 판단했다. 그래서 범용성보다는 명확한 이름과 안전한 참조를 우선해서 구체적인 enum 구조로 잡았다.

이벤트에는 파라미터도 붙일 수 있게 만들었다. 인스펙터에서 이벤트를 정의하고, 필요한 경우 이벤트별로 EventParameter를 추가하는 방식이다.

인스펙터에서 이벤트와 파라미터를 함께 정의하는 화면

예를 들어 같은 공격 이벤트라도 어떤 무기인지, 어느 판정을 켤 것인지, 어떤 이펙트를 사용할 것인지 같은 추가 정보가 필요할 수 있다. 이 정보를 이벤트 이름에 모두 섞어 넣으면 이름이 금방 지저분해진다. 그래서 이벤트 이름은 WellknownEventName으로 고정하고, 세부 정보는 파라미터로 넘길 수 있게 했다.

파라미터 타입 목록은 에디터 코드에서 Reflection으로 가져오도록 했다. EventParameter 클래스를 상속받은 타입들을 찾아서 인스펙터에서 선택할 수 있게 만든 것이다. 이렇게 해두면 새로운 파라미터 타입을 추가할 때마다 인스펙터 코드를 직접 고치지 않아도 된다. 파라미터 클래스만 추가하면 에디터가 후보 목록에 올려주는 구조다.

이 연결 덕분에 Animator State 프리뷰에서 타임라인 바를 움직이면 Event Raiser 쪽 타임라인 바도 같이 움직인다.

Animator State 프리뷰와 Event Raiser 타임라인 바 동기화

애니메이션을 눈으로 보면서 원하는 순간에 타임라인을 맞추고, 같은 위치를 Event Raiser에서 바로 확인할 수 있으니 이벤트를 넣기가 훨씬 수월해졌다. 예전처럼 클립을 따로 열고, 시간을 맞춘 뒤, 다시 스테이트로 돌아와 확인하는 흐름보다 작업감이 훨씬 낫다.

핵심이 된 코드는 이런 형태다.

public enum EventTiming
{
    OnEnter,
    OnUpdate,
    OnExit
}

public class AnimatorStateEventRaiser : StateMachineBehaviour, ISerializationCallbackReceiver
{
    [Serializable]
    public class Event
    {
        public EventTiming timing = EventTiming.OnUpdate;
        public float normalizedTime = 0f;
        public WellknownEventName eventName;

        [SerializeReference]
        public EventParameter parameter;

        public bool invoked;

        public void Invoke(Animator animator)
        {
            var eventListener = animator.GetComponent<NetworkAnimatorEventListener>();
            UnityAssert.IsNotNull(eventListener, "NetworkAnimatorEventListener component not found on the Animator GameObject.");
            eventListener.AddEvent(eventName, parameter);
        }
    }

    [SerializeField] public List<Event> events = new List<Event>();
    float previousNormalizedTime = 0f;

    public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        foreach (var evt in events)
        {
            evt.invoked = false;
        }

        previousNormalizedTime = 0f;

        foreach (var evt in events)
        {
            if (evt.timing == EventTiming.OnEnter)
            {
                evt.Invoke(animator);
                evt.invoked = true;
            }
        }
    }

    public override void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        float currentNormalizedTime = stateInfo.normalizedTime % 1f;

        for (int i = 0; i < events.Count; i++)
        {
            if (events[i].timing == EventTiming.OnUpdate &&
                !events[i].invoked &&
                events[i].normalizedTime <= currentNormalizedTime)
            {
                events[i].Invoke(animator);
                events[i].invoked = true;
            }
        }

        previousNormalizedTime = stateInfo.normalizedTime;
    }

    public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
    {
        foreach (var evt in events)
        {
            if (evt.timing == EventTiming.OnExit)
            {
                evt.Invoke(animator);
            }
        }
    }

    public void OnBeforeSerialize()
    {
        events.Sort((a, b) => a.normalizedTime.CompareTo(b.normalizedTime));
    }

    public void OnAfterDeserialize()
    {
    }
}

이 구조를 쓰면 공격 애니메이션의 특정 프레임에서 판정을 켜거나, 장전 애니메이션의 중간 지점에서 탄약 상태를 바꾸는 식의 처리를 Animator State 기준으로 관리할 수 있다. 이벤트를 NetworkAnimatorEventListener로 넘기기 때문에, 나중에 네트워크 애니메이션 이벤트 흐름과도 연결하기 쉬운 형태가 된다.

아직은 큰 이벤트 시스템이라기보다, 애니메이션 시점과 게임 로직 사이를 잇는 작은 연결 장치에 가깝다. 그래도 프리뷰 기능과 함께 쓰면 스테이트를 보면서 "이 애니메이션의 어느 시점에 어떤 이벤트가 나가는지"를 한 화면에서 확인할 수 있어서 꽤 편해졌다.

현재 프로젝트에서 사용한 곳

이 도구는 단순히 만들어보기만 한 것이 아니라, 현재 프로젝트의 실제 애니메이션 처리에도 바로 사용했다.

이렇게 하니 애니메이션 타이밍과 게임 로직이 더 자연스럽게 맞아떨어졌다. 특히 재장전이나 공격처럼 "보이는 동작"과 "실제 로직 실행 시점"이 어긋나면 어색해지는 기능에서 효과가 컸다.

발생한 이벤트를 듣는 쪽

이벤트를 발생시키면 누군가는 그것을 들어야 한다. 그래서 AnimatorStateEventRaiser가 바로 게임 로직을 실행하지 않고, NetworkAnimatorEventListener에 이벤트를 넘기는 구조로 만들었다.

이 Listener는 Animator가 붙어 있는 오브젝트에서 이벤트 요청을 큐에 쌓고, Fusion의 FixedUpdateNetwork 안에서 처리한다. Proxy에서는 이벤트를 넣지 않고, Forward tick이 아닐 때도 무시한다. 실제 콜백 실행은 State Authority를 가진 쪽에서만 처리하도록 했다.

물론 이 코드는 Fusion을 사용한다는 가정으로 만들어진 것이다. Fusion을 사용하지 않거나 다른 네트워크 프레임워크를 사용한다면, 이벤트를 큐에 넣고 적절한 타이밍에 콜백을 실행하는 부분만 프로젝트에 맞게 바꾸면 된다.

코드는 다음과 같다.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Fusion;
using UnityAssert = UnityEngine.Assertions.Assert;

namespace Invasion.Animation
{
    public class NetworkAnimatorEventListener : NetworkBehaviour
    {
        struct Request
        {
            public WellknownEventName requestName;
            public EventParameter parameter;
        }

        Queue<Request> requestQ = new Queue<Request>();
        protected Dictionary<WellknownEventName, Action<EventParameter>> callbacks
            = new Dictionary<WellknownEventName, Action<EventParameter>>();

        public ReadOnlyDictionary<WellknownEventName, Action<EventParameter>> Callbacks
            => new ReadOnlyDictionary<WellknownEventName, Action<EventParameter>>(callbacks);

        public void AddEvent(WellknownEventName requestName, EventParameter parameter = null)
        {
            if (IsProxy)
                return;

            if (!Runner.IsForward)
                return;

            requestQ.Enqueue(new Request
            {
                requestName = requestName,
                parameter = parameter,
            });
        }

        public override void FixedUpdateNetwork()
        {
            if (!HasStateAuthority)
                return;

            while (requestQ.Count > 0)
            {
                Request request = requestQ.Dequeue();
                if (callbacks.TryGetValue(request.requestName, out Action<EventParameter> action))
                {
                    action?.Invoke(request.parameter);
                }
            }
        }

        public void AddCallback(WellknownEventName requestName, Action<EventParameter> action)
        {
            if (!callbacks.ContainsKey(requestName))
                callbacks.Add(requestName, action);
            else
                callbacks[requestName] += action;
        }

        public void RemoveCallback(WellknownEventName requestName, Action<EventParameter> action)
        {
            UnityAssert.IsTrue(callbacks.ContainsKey(requestName));

            callbacks[requestName] -= action;
        }
    }
}

이렇게 나누면 Animator State는 "언제 어떤 이벤트가 발생하는가"만 담당하고, 실제로 그 이벤트를 받아 무엇을 할지는 Listener에 등록된 콜백이 처리한다. 공격 판정, 이펙트, 사운드, 장전 처리 같은 로직을 이벤트 이름 기준으로 연결할 수 있어서 스테이트와 게임 로직 사이의 결합도도 줄어든다.

작은 도구가 주는 효과

이번 작업은 게임의 핵심 시스템을 만드는 일은 아니었다. 하지만 실제 제작 과정에서는 이런 작은 도구가 생각보다 중요하다. 반복해서 확인해야 하는 작업의 마찰을 줄이면, 기능 구현에 쓰는 집중력을 조금 더 아낄 수 있다.

애니메이션은 특히 눈으로 확인해야 하는 일이 많다. 코드처럼 텍스트만 보고 판단하기 어렵고, 재생해봐야 감이 오는 경우가 많다. 그래서 확인까지의 거리를 줄이는 것만으로도 작업 속도가 꽤 달라진다.

이번 Animator State 프리뷰와 이벤트 발생 기능은 그런 의미에서 마음에 드는 작업이었다. 게임 안에 직접 보이는 기능은 아니지만, 게임을 만드는 사람의 손을 조금 편하게 해주는 기능이기 때문이다.

다음에는 이 기능들을 실제 애니메이션 작업에 써보면서, 추가로 필요한 기능이 있는지 확인해볼 생각이다. 지금은 큰 편집기보다 작고 확실한 Animator State 도구로 시작하는 편이 이 프로젝트에 더 맞는 것 같다.

아직 못다한 것들

VFX 생성하기

여기에 VFX 생성 이벤트를 만들고, 미리보기를 재생했을 때 실제 이펙트까지 나오게 만들고 싶은 생각도 있었다. 하지만 Animation Clip 프리뷰 안에서 이 기능을 처리하려면 Reflection을 지금보다 더 많이 사용해야 할 것 같다. 내부 프리뷰 오브젝트나 재생 흐름에 더 깊게 접근해야 할 가능성이 높고, 그만큼 공수가 너무 커 보였다.

이런 기능을 정말 만들어야 하는 날이 오면, Animation Clip 프리뷰에 억지로 붙이기보다는 State 이벤트 편집용 윈도우를 따로 만들어서 처리해야 할 것 같다. 다만 그 방식도 작업량이 꽤 많아질 것 같아서, 이번에는 나중으로 미루기로 했다.

블렌드 트리용 미리보기

아직 Blend Tree State에 대해서는 프리뷰 기능을 만들지 않았다. 현재는 일반 Animation Clip이 직접 연결된 State를 먼저 확인할 수 있게 만든 상태다. Blend Tree는 파라미터 값에 따라 여러 클립이 섞이는 구조라서, 단순히 하나의 클립을 가져와 보여주는 것보다 처리해야 할 부분이 더 많다.

그래서 이번 단계에서는 범위에서 제외했고, 나중에 이동 애니메이션을 더 많이 다루게 되면 별도로 확장해볼 생각이다.

참고한 자료