Unity AI 개발: xNode 기반 그래픽 FSM 튜토리얼
게시 됨: 2022-08-12"Unity AI Development: A Finite-state Machine Tutorial"에서 우리는 모듈식 FSM 기반 AI인 간단한 스텔스 게임을 만들었습니다. 게임에서 적 에이전트는 게임 공간을 순찰합니다. 플레이어를 발견하면 적은 상태를 변경하고 순찰하는 대신 플레이어를 따라갑니다.
Unity 여정의 두 번째 단계에서는 향상된 Unity 개발자 경험을 통해 FSM(Finite-State Machine)의 핵심 구성 요소를 보다 빠르게 생성하기 위한 GUI(그래픽 사용자 인터페이스)를 구축합니다.
빠른 리프레셔
이전 자습서에서 자세히 설명한 FSM은 아키텍처 블록을 C# 스크립트로 빌드했습니다. 사용자 지정 ScriptableObject
작업 및 결정을 클래스로 추가했습니다. ScriptableObject
접근 방식을 통해 FSM을 쉽게 유지 관리하고 사용자 지정할 수 있습니다. 이 자습서에서는 FSM의 끌어서 놓기 ScriptableObject
를 그래픽 옵션으로 바꿉니다.
나는 또한 게임을 더 쉽게 이기고자 하는 여러분을 위해 업데이트된 스크립트를 작성했습니다. 구현하려면 플레이어 감지 스크립트를 적의 시야를 좁히는 스크립트로 바꾸면 됩니다.
xNode 시작하기
FSM의 흐름을 시각적으로 표시하는 노드 기반 동작 트리용 프레임워크인 xNode를 사용하여 그래픽 편집기를 빌드합니다. Unity의 GraphView가 작업을 수행할 수 있지만 해당 API는 실험적이며 문서화되어 있지 않습니다. xNode의 사용자 인터페이스는 우수한 개발자 경험을 제공하여 FSM의 프로토타이핑 및 신속한 확장을 촉진합니다.
Unity 패키지 관리자를 사용하여 xNode를 Git 종속성으로 프로젝트에 추가해 보겠습니다.
- Unity에서 창 > 패키지 관리자 를 클릭하여 패키지 관리자 창을 시작합니다.
- 창의 왼쪽 상단 모서리에 있는 + (더하기 기호)를 클릭 하고 git URL에서 패키지 추가 를 선택하여 텍스트 필드를 표시합니다.
- 레이블이 지정되지 않은 텍스트 상자에
https://github.com/siccity/xNode.git
을 입력하거나 붙여넣고 추가 버튼을 클릭합니다.
이제 xNode의 주요 구성 요소를 자세히 살펴보고 이해할 준비가 되었습니다.
Node 클래스 | 그래프의 가장 기본적인 단위인 노드를 나타냅니다. 이 xNode 자습서에서는 사용자 지정 기능 및 역할이 장착된 노드를 선언하는 새 클래스를 Node 클래스에서 파생합니다. |
NodeGraph 클래스 | 노드 모음( Node 클래스 인스턴스)과 노드를 연결하는 가장자리를 나타냅니다. 이 xNode 튜토리얼에서는 노드를 조작하고 평가하는 새 클래스를 NodeGraph 에서 파생합니다. |
NodePort 클래스 | NodeGraph 의 Node 인스턴스 사이에 있는 입력 또는 출력 유형의 포트인 통신 게이트를 나타냅니다. NodePort 클래스는 xNode에 고유합니다. |
[Input] 속성 | [Input] 속성을 포트에 추가하면 포트가 입력으로 지정되어 포트가 속한 노드에 값을 전달할 수 있습니다. [Input] 속성을 함수 매개변수로 생각하십시오. |
[Output] 속성 | 포트에 [Output] 속성을 추가하면 포트가 출력으로 지정되어 포트가 속한 노드의 값을 전달할 수 있습니다. [Output] 속성을 함수의 반환 값으로 생각하십시오. |
xNode 구축 환경 시각화
xNode에서 우리는 각 State
와 Transition
이 노드의 형태를 취하는 그래프로 작업합니다. 입력 및/또는 출력 연결을 통해 노드는 그래프의 다른 모든 노드와 관련될 수 있습니다.
임의의 값 2개와 부울 값 1개 등 세 개의 입력 값이 있는 노드를 상상해 보겠습니다. 노드는 부울 입력이 참인지 거짓인지에 따라 두 개의 임의 유형 입력 값 중 하나를 출력합니다.
기존 FSM을 그래프로 변환하기 위해 State
및 Transition
클래스를 수정하여 ScriptableObject
클래스 대신 Node
클래스를 상속합니다. 모든 State
및 Transition
개체를 포함하는 NodeGraph
유형의 그래프 개체를 만듭니다.
기본 유형으로 사용하도록 BaseStateMachine
수정
기존 BaseStateMachine
클래스에 두 개의 새로운 가상 메서드를 추가하여 그래픽 인터페이스 구축을 시작합니다.
Init | CurrentState 속성에 초기 상태를 할당합니다. |
Execute | 현재 상태를 실행 |
이러한 메서드를 가상으로 선언하면 재정의할 수 있으므로 초기화 및 실행을 위해 BaseStateMachine
클래스를 상속하는 클래스의 사용자 지정 동작을 정의할 수 있습니다.
using System; using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private Dictionary<Type, Component> _cachedComponents; private void Awake() { Init(); _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { Execute(); } public virtual void Init() { CurrentState = _initialState; } public virtual void Execute() { CurrentState.Execute(this); } // Allows us to execute consecutive calls of GetComponent in O(1) time public new T GetComponent<T>() where T : Component { if(_cachedComponents.ContainsKey(typeof(T))) return _cachedComponents[typeof(T)] as T; var component = base.GetComponent<T>(); if(component != null) { _cachedComponents.Add(typeof(T), component); } return component; } } }
다음으로 FSM
폴더 아래에 다음을 생성해 보겠습니다.
FSMGraph | 폴더 |
BaseStateMachineGraph | FSMGraph 내의 AC# 클래스 |
당분간 BaseStateMachineGraph
는 BaseStateMachine
클래스만 상속합니다.
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }
기본 노드 유형을 생성할 때까지 BaseStateMachineGraph
에 기능을 추가할 수 없습니다. 다음에 합시다.
NodeGraph
구현 및 기본 노드 유형 생성
새로 생성된 FSMGraph
폴더 아래에 다음을 생성합니다.
FSMGraph | 수업 |
현재 FSMGraph
는 NodeGraph
클래스만 상속합니다(추가된 기능 없음).
using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }
노드에 대한 클래스를 만들기 전에 다음을 추가해 보겠습니다.
FSMNodeBase | 모든 노드에서 기본 클래스로 사용할 클래스 |
FSMNodeBase
클래스에는 노드를 서로 연결할 수 있도록 FSMNodeBase
유형의 Entry
라는 입력이 포함됩니다.
또한 두 가지 도우미 기능을 추가합니다.
GetFirst | 요청된 출력에 연결된 첫 번째 노드를 검색합니다. |
GetAllOnPort | 요청된 출력에 연결된 나머지 노드를 모두 검색합니다. |
using System.Collections.Generic; using XNode; namespace Demo.FSM.Graph { public abstract class FSMNodeBase : Node { [Input(backingValue = ShowBackingValue.Never)] public FSMNodeBase Entry; protected IEnumerable<T> GetAllOnPort<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); for (var portIndex = 0; portIndex < port.ConnectionCount; portIndex++) { yield return port.GetConnection(portIndex).node as T; } } protected T GetFirst<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); if (port.ConnectionCount > 0) return port.GetConnection(0).node as T; return null; } } }
궁극적으로 두 가지 유형의 상태 노드가 있습니다. 이를 지원하는 클래스를 추가해 보겠습니다.
BaseStateNode | StateNode 와 RemainInStateNode 를 모두 지원하는 기본 클래스 |
namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }
다음으로 BaseStateMachineGraph
클래스를 수정합니다.
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }
여기에서는 기본 클래스에서 상속된 CurrentState
속성을 숨기고 해당 유형을 BaseState
에서 BaseStateNode
로 변경했습니다.
FSM 그래프를 위한 빌딩 블록 만들기
다음으로, FSM의 주요 빌딩 블록을 형성하기 위해 FSMGraph
폴더에 세 개의 새 클래스를 추가해 보겠습니다.
StateNode | 에이전트의 상태를 나타냅니다. 실행 시 StateNode 는 StateNode 의 출력 포트에 연결된 TransitionNode 를 반복합니다(도우미 메서드에 의해 검색됨). StateNode 는 노드를 다른 상태로 전환할지 아니면 노드의 상태를 그대로 둘지 각각을 쿼리합니다. |
RemainInStateNode | 노드가 현재 상태를 유지해야 함을 나타냅니다. |
TransitionNode | 다른 상태로 전환하거나 동일한 상태를 유지하기로 결정합니다. |
이전 Unity FSM 튜토리얼에서 State
클래스는 전환 목록을 반복합니다. 여기 xNode에서 StateNode
는 GetAllOnPort
도우미 메서드를 통해 검색된 노드를 반복하는 것과 동일한 State
의 역할을 합니다.
이제 나가는 연결(전환 노드)에 [Output]
속성을 추가하여 GUI의 일부여야 함을 나타냅니다. xNode의 설계에 따라 속성 값은 [Output]
속성으로 표시된 필드를 포함하는 노드인 소스 노드에서 시작됩니다. xNode GUI에 의해 설정될 관계와 연결을 설명하기 위해 [Output]
및 [Input]
속성을 사용하므로 이러한 값을 일반적으로 처리할 수 없습니다. Actions
대 Transitions
을 통해 반복하는 방법을 고려하십시오.
using System.Collections.Generic; namespace Demo.FSM.Graph { [CreateNodeMenu("State")] public sealed class StateNode : BaseStateNode { public List<FSMAction> Actions; [Output] public List<TransitionNode> Transitions; public void Execute(BaseStateMachineGraph baseStateMachine) { foreach (var action in Actions) action.Execute(baseStateMachine); foreach (var transition in GetAllOnPort<TransitionNode>(nameof(Transitions))) transition.Execute(baseStateMachine); } } }
이 경우 Transitions
출력에는 여러 노드가 연결될 수 있습니다. [Output]
연결 목록을 얻으려면 GetAllOnPort
도우미 메서드를 호출해야 합니다.
RemainInStateNode
는 지금까지 가장 단순한 클래스입니다. 논리를 실행하지 않고 RemainInStateNode
는 에이전트(게임의 경우 적)에게 현재 상태를 유지하도록 지시할 뿐입니다.
namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }
이 시점에서 TransitionNode
클래스는 여전히 불완전하며 컴파일되지 않습니다. 클래스를 업데이트하면 연결된 오류가 지워집니다.
TransitionNode
를 빌드하려면 StateNode
를 빌드할 때 했던 것처럼 출력 값이 소스 노드에서 시작된다는 xNode의 요구 사항을 해결해야 합니다. StateNode
와 TransitionNode
의 주요 차이점은 TransitionNode
의 출력이 하나의 노드에만 첨부될 수 있다는 것입니다. 우리의 경우 GetFirst
는 각 포트에 연결된 하나의 노드를 가져옵니다.
namespace Demo.FSM.Graph { [CreateNodeMenu("Transition")] public sealed class TransitionNode : FSMNodeBase { public Decision Decision; [Output] public BaseStateNode TrueState; [Output] public BaseStateNode FalseState; public void Execute(BaseStateMachineGraph stateMachine) { var trueState = GetFirst<BaseStateNode>(nameof(TrueState)); var falseState = GetFirst<BaseStateNode>(nameof(FalseState)); var decision = Decision.Decide(stateMachine); if (decision && !(trueState is RemainInStateNode)) { stateMachine.CurrentState = trueState; } else if(!decision && !(falseState is RemainInStateNode)) stateMachine.CurrentState = falseState; } } }
코드의 그래픽 결과를 살펴보겠습니다.
시각적 그래프 만들기
모든 FSM 클래스가 정렬되면 게임의 적 에이전트에 대한 FSM 그래프 생성을 진행할 수 있습니다. Unity 프로젝트 창에서 EnemyAI
폴더를 마우스 오른쪽 버튼으로 클릭하고 Create > FSM > FSM Graph 를 선택합니다. 그래프를 더 쉽게 식별할 수 있도록 이름을 EnemyGraph
로 바꾸겠습니다.
xNode 그래프 편집기 창에서 마우스 오른쪽 버튼을 클릭하여 State , Transition 및 RemainInState 가 나열된 드롭다운 메뉴를 표시합니다. 창이 표시되지 않으면 EnemyGraph
파일을 두 번 클릭하여 xNode 그래프 편집기 창을 시작합니다.
Chase
및Patrol
상태를 생성하려면:마우스 오른쪽 버튼을 클릭하고 상태 를 선택하여 새 노드를 만듭니다.
노드 이름을
Chase
로 지정합니다.드롭다운 메뉴로 돌아가서 상태 를 다시 선택하여 두 번째 노드를 만듭니다.
노드 이름을
Patrol
로 지정합니다.기존
Chase
및Patrol
작업을 새로 생성된 해당 상태로 끌어다 놓습니다.
전환을 생성하려면:
마우스 오른쪽 버튼을 클릭하고 전환 을 선택하여 새 노드를 만듭니다.
LineOfSightDecision
개체를 전환의Decision
필드에 할당합니다.
RemainInState
노드를 생성하려면:- 마우스 오른쪽 버튼을 클릭하고 RemainInState 를 선택하여 새 노드를 만듭니다.
그래프를 연결하려면:
Patrol
노드의Transitions
출력을Transition
노드의Entry
입력에 연결합니다.Transition
노드의True State
출력을Chase
노드의Entry
입력에 연결합니다.Transition
노드의False State
출력을Remain In State
노드의Entry
입력에 연결합니다.
그래프는 다음과 같아야 합니다.
그래프에서 어떤 노드( Patrol
또는 Chase
상태)가 초기 노드인지 나타내지 않습니다. BaseStateMachineGraph
클래스는 4개의 노드를 감지하지만 표시기가 없으면 초기 상태를 선택할 수 없습니다.
이 문제를 해결하려면 다음을 생성해 보겠습니다.
FSMInitialNode | StateNode 유형의 단일 출력 이름이 InitialNode 인 클래스 |
출력 InitialNode
는 초기 상태를 나타냅니다. 다음으로 FSMInitialNode
에서 다음을 만듭니다.
NextNode | InitialNode 출력에 연결된 노드를 가져올 수 있는 속성 |
using XNode; namespace Demo.FSM.Graph { [CreateNodeMenu("Initial Node"), NodeTint("#00ff52")] public class FSMInitialNode : Node { [Output] public StateNode InitialNode; public StateNode NextNode { get { var port = GetOutputPort("InitialNode"); if (port == null || port.ConnectionCount == 0) return null; return port.GetConnection(0).node as StateNode; } } } }
이제 FSMInitialNode
클래스를 만들었으므로 이를 초기 상태의 Entry
입력에 연결하고 NextNode
속성을 통해 초기 상태를 반환할 수 있습니다.
그래프로 돌아가서 초기 노드를 추가해 보겠습니다. xNode 편집기 창에서:
- 마우스 오른쪽 버튼을 클릭하고 초기 노드 를 선택하여 새 노드를 생성합니다.
- FSM 노드 의 출력을
Patrol
노드의Entry
입력에 연결합니다.
그래프는 이제 다음과 같아야 합니다.
우리의 삶을 더 쉽게 만들기 위해 우리는 FSMGraph
에 추가할 것입니다:
InitialState | 속성 |
처음으로 InitialState
속성의 값을 검색하려고 하면 속성의 getter가 FSMInitialNode
를 찾으려고 할 때 그래프의 모든 노드를 탐색합니다. FSMInitialNode
를 찾으면 NextNode
속성을 사용하여 초기 상태 노드를 찾습니다.
using System.Linq; using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public sealed class FSMGraph : NodeGraph { private StateNode _initialState; public StateNode InitialState { get { if (_initialState == null) _initialState = FindInitialStateNode(); return _initialState; } } private StateNode FindInitialStateNode() { var initialNode = nodes.FirstOrDefault(x => x is FSMInitialNode); if (initialNode != null) { return (initialNode as FSMInitialNode).NextNode; } return null; } } }
다음으로 BaseStateMachineGraph
에서 BaseStateMachine
를 참조하고 FSMGraph
의 Init
및 Execute
메서드를 재정의하겠습니다. Init
를 재정의하면 CurrentState
가 그래프의 초기 상태로 설정되고 Execute
를 재정의하면 CurrentState
에서 Execute
가 호출됩니다.
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { [SerializeField] private FSMGraph _graph; public new BaseStateNode CurrentState { get; set; } public override void Init() { CurrentState = _graph.InitialState; } public override void Execute() { ((StateNode)CurrentState).Execute(this); } } }
이제 그래프를 Enemy 개체에 적용하고 작동하는 모습을 살펴보겠습니다.
FSM 그래프 테스트
테스트 준비를 위해 Unity 에디터의 프로젝트 창에서:
SampleScene 자산을 엽니다.
Unity 계층 창에서
Enemy
게임 개체를 찾습니다.BaseStateMachineGraph
구성 요소를BaseStateMachine
구성 요소로 교체합니다.구성요소 추가 를 클릭하고 올바른
BaseStateMachineGraph
스크립트를 선택하십시오.FSM 그래프
EnemyGraph
를BaseStateMachineGraph
구성 요소의Graph
필드에 할당합니다.마우스 오른쪽 버튼을 클릭하고 구성 요소 제거 를 선택하여
BaseStateMachine
구성 요소를 삭제합니다(더 이상 필요하지 않음).
Enemy
게임 개체는 다음과 같아야 합니다.
그게 다야! 이제 그래픽 편집기가 있는 모듈식 FSM이 있습니다. 재생 버튼을 클릭하면 그래픽으로 생성된 적 AI가 이전에 생성한 ScriptableObject
적과 똑같이 작동한다는 것을 알 수 있습니다.
Forging Ahead: FSM 최적화
주의 사항: 게임을 위해 보다 정교한 AI를 개발함에 따라 상태 및 전환 수가 증가하고 FSM이 혼란스럽고 읽기 어려워집니다. 그래픽 편집기는 여러 상태에서 시작하여 여러 전환에서 끝나는 선의 웹과 유사하게 성장하며 그 반대의 경우도 마찬가지이므로 FSM을 디버그하기가 어렵습니다.
이전 자습서에서와 같이 코드를 직접 만들고, 스텔스 게임을 최적화하고, 이러한 문제를 해결하도록 초대합니다. 노드가 활성 또는 비활성인지 여부를 나타내기 위해 상태 노드를 색상으로 구분하거나 화면 공간을 제한하기 위해 RemainInState
및 Initial
노드의 크기를 조정하는 것이 얼마나 도움이 될지 상상해 보십시오.
이러한 향상은 단순히 미용적인 것이 아닙니다. 색상 및 크기 참조는 디버그할 위치와 시기를 식별하는 데 도움이 됩니다. 눈으로 보기 쉬운 그래프는 평가, 분석 및 이해하기도 더 쉽습니다. 다음 단계는 모두 사용자에게 달려 있습니다. 그래픽 편집기를 기반으로 하면 개발자 환경을 개선하는 데 제한이 없습니다.
Toptal 엔지니어링 블로그에 대한 추가 정보:
- Unity 개발자가 저지르는 가장 일반적인 실수 10가지
- MVC를 사용한 Unity: 게임 개발 수준을 높이는 방법
- Unity에서 2D 카메라 마스터하기: 게임 개발자를 위한 튜토리얼
- Toptal 개발자의 Unity 모범 사례 및 팁
Toptal 엔지니어링 블로그는 이 기사의 전문 지식과 기술 검토에 대해 Goran Lalic에게 감사를 표합니다.