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 종속성으로 프로젝트에 추가해 보겠습니다.

  1. Unity에서 창 > 패키지 관리자 를 클릭하여 패키지 관리자 창을 시작합니다.
  2. 창의 왼쪽 상단 모서리에 있는 + (더하기 기호)를 클릭 하고 git URL에서 패키지 추가 를 선택하여 텍스트 필드를 표시합니다.
  3. 레이블이 지정되지 않은 텍스트 상자에 https://github.com/siccity/xNode.git 을 입력하거나 붙여넣고 추가 버튼을 클릭합니다.

이제 xNode의 주요 구성 요소를 자세히 살펴보고 이해할 준비가 되었습니다.

Node 클래스 그래프의 가장 기본적인 단위인 노드를 나타냅니다. 이 xNode 자습서에서는 사용자 지정 기능 및 역할이 장착된 노드를 선언하는 새 클래스를 Node 클래스에서 파생합니다.
NodeGraph 클래스 노드 모음( Node 클래스 인스턴스)과 노드를 연결하는 가장자리를 나타냅니다. 이 xNode 튜토리얼에서는 노드를 조작하고 평가하는 새 클래스를 NodeGraph 에서 파생합니다.
NodePort 클래스 NodeGraphNode 인스턴스 사이에 있는 입력 또는 출력 유형의 포트인 통신 게이트를 나타냅니다. NodePort 클래스는 xNode에 고유합니다.
[Input] 속성 [Input] 속성을 포트에 추가하면 포트가 입력으로 지정되어 포트가 속한 노드에 값을 전달할 수 있습니다. [Input] 속성을 함수 매개변수로 생각하십시오.
[Output] 속성 포트에 [Output] 속성을 추가하면 포트가 출력으로 지정되어 포트가 속한 노드의 값을 전달할 수 있습니다. [Output] 속성을 함수의 반환 값으로 생각하십시오.

xNode 구축 환경 시각화

xNode에서 우리는 각 StateTransition 이 노드의 형태를 취하는 그래프로 작업합니다. 입력 및/또는 출력 연결을 통해 노드는 그래프의 다른 모든 노드와 관련될 수 있습니다.

임의의 값 2개와 부울 값 1개 등 세 개의 입력 값이 있는 노드를 상상해 보겠습니다. 노드는 부울 입력이 참인지 거짓인지에 따라 두 개의 임의 유형 입력 값 중 하나를 출력합니다.

중앙에 큰 직사각형으로 표시되는 분기 노드에는 "If C == True A Else B"라는 의사 코드가 포함되어 있습니다. 왼쪽에는 "A(임의)", "B(임의)" 및 "C(부울)"와 같이 각각 분기 노드를 가리키는 화살표가 있는 세 개의 직사각형이 있습니다. 분기 노드에는 마지막으로 "출력" 직사각형을 가리키는 화살표가 있습니다.
Branch 노드의 예

기존 FSM을 그래프로 변환하기 위해 StateTransition 클래스를 수정하여 ScriptableObject 클래스 대신 Node 클래스를 상속합니다. 모든 StateTransition 개체를 포함하는 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# 클래스

당분간 BaseStateMachineGraphBaseStateMachine 클래스만 상속합니다.

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }

기본 노드 유형을 생성할 때까지 BaseStateMachineGraph 에 기능을 추가할 수 없습니다. 다음에 합시다.

NodeGraph 구현 및 기본 노드 유형 생성

새로 생성된 FSMGraph 폴더 아래에 다음을 생성합니다.

FSMGraph 수업

현재 FSMGraphNodeGraph 클래스만 상속합니다(추가된 기능 없음).

 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 StateNodeRemainInStateNode 를 모두 지원하는 기본 클래스
 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 에이전트의 상태를 나타냅니다. 실행 시 StateNodeStateNode 의 출력 포트에 연결된 TransitionNode 를 반복합니다(도우미 메서드에 의해 검색됨). StateNode 는 노드를 다른 상태로 전환할지 아니면 노드의 상태를 그대로 둘지 각각을 쿼리합니다.
RemainInStateNode 노드가 현재 상태를 유지해야 함을 나타냅니다.
TransitionNode 다른 상태로 전환하거나 동일한 상태를 유지하기로 결정합니다.

이전 Unity FSM 튜토리얼에서 State 클래스는 전환 목록을 반복합니다. 여기 xNode에서 StateNodeGetAllOnPort 도우미 메서드를 통해 검색된 노드를 반복하는 것과 동일한 State 의 역할을 합니다.

이제 나가는 연결(전환 노드)에 [Output] 속성을 추가하여 GUI의 일부여야 함을 나타냅니다. xNode의 설계에 따라 속성 값은 [Output] 속성으로 표시된 필드를 포함하는 노드인 소스 노드에서 시작됩니다. xNode GUI에 의해 설정될 관계와 연결을 설명하기 위해 [Output][Input] 속성을 사용하므로 이러한 값을 일반적으로 처리할 수 없습니다. ActionsTransitions 을 통해 반복하는 방법을 고려하십시오.

 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의 요구 사항을 해결해야 합니다. StateNodeTransitionNode 의 주요 차이점은 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 , TransitionRemainInState 가 나열된 드롭다운 메뉴를 표시합니다. 창이 표시되지 않으면 EnemyGraph 파일을 두 번 클릭하여 xNode 그래프 편집기 창을 시작합니다.

  1. ChasePatrol 상태를 생성하려면:

    1. 마우스 오른쪽 버튼을 클릭하고 상태 를 선택하여 새 노드를 만듭니다.

    2. 노드 이름을 Chase 로 지정합니다.

    3. 드롭다운 메뉴로 돌아가서 상태 를 다시 선택하여 두 번째 노드를 만듭니다.

    4. 노드 이름을 Patrol 로 지정합니다.

    5. 기존 ChasePatrol 작업을 새로 생성된 해당 상태로 끌어다 놓습니다.

  2. 전환을 생성하려면:

    1. 마우스 오른쪽 버튼을 클릭하고 전환 을 선택하여 새 노드를 만듭니다.

    2. LineOfSightDecision 개체를 전환의 Decision 필드에 할당합니다.

  3. RemainInState 노드를 생성하려면:

    1. 마우스 오른쪽 버튼을 클릭하고 RemainInState 를 선택하여 새 노드를 만듭니다.
  4. 그래프를 연결하려면:

    1. Patrol 노드의 Transitions 출력을 Transition 노드의 Entry 입력에 연결합니다.

    2. Transition 노드의 True State 출력을 Chase 노드의 Entry 입력에 연결합니다.

    3. Transition 노드의 False State 출력을 Remain In State 노드의 Entry 입력에 연결합니다.

그래프는 다음과 같아야 합니다.

네 개의 노드는 네 개의 직사각형으로 표시되며 각 노드는 왼쪽 상단에 항목 입력 원이 있습니다. 왼쪽에서 오른쪽으로 순찰 상태 노드에는 순찰 작업이라는 하나의 작업이 표시됩니다. 순찰 상태 노드에는 오른쪽 하단에 전환 노드의 진입 원에 연결되는 전환 출력 원도 포함됩니다. 전환 노드는 LineOfSight라는 하나의 결정을 표시합니다. 오른쪽 하단에 True State와 False State라는 두 개의 출력 원이 있습니다. True State는 세 번째 구조인 Chase state 노드의 Entry circle에 연결됩니다. 추적 상태 노드는 추적 작업이라는 하나의 작업을 표시합니다. 체이스 상태 노드에는 전환 출력 원이 있습니다. Transition의 두 출력 원 중 두 번째인 False State는 네 번째이자 최종 구조인 RemainInState 노드(Chase 상태 노드 아래에 표시됨)의 Entry 원에 연결됩니다.
FSM 그래프의 초기 모습

그래프에서 어떤 노드( 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 편집기 창에서:

  1. 마우스 오른쪽 버튼을 클릭하고 초기 노드 를 선택하여 새 노드를 생성합니다.
  2. FSM 노드 의 출력을 Patrol 노드의 Entry 입력에 연결합니다.

그래프는 이제 다음과 같아야 합니다.

이전 이미지와 동일한 그래프로, 다른 네 개의 직사각형 왼쪽에 하나의 FSM 노드 녹색 직사각형이 추가되었습니다. Patrol 노드의 "Entry" 입력(진한 빨간색 원으로 표시)에 연결되는 초기 노드 출력(파란색 원으로 표시)이 있습니다.
순찰 상태에 연결된 초기 노드가 있는 FSM 그래프

우리의 삶을 더 쉽게 만들기 위해 우리는 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 를 참조하고 FSMGraphInitExecute 메서드를 재정의하겠습니다. 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 에디터의 프로젝트 창에서:

  1. SampleScene 자산을 엽니다.

  2. Unity 계층 창에서 Enemy 게임 개체를 찾습니다.

  3. BaseStateMachineGraph 구성 요소를 BaseStateMachine 구성 요소로 교체합니다.

    1. 구성요소 추가 를 클릭하고 올바른 BaseStateMachineGraph 스크립트를 선택하십시오.

    2. FSM 그래프 EnemyGraphBaseStateMachineGraph 구성 요소의 Graph 필드에 할당합니다.

    3. 마우스 오른쪽 버튼을 클릭하고 구성 요소 제거 를 선택하여 BaseStateMachine 구성 요소를 삭제합니다(더 이상 필요하지 않음).

Enemy 게임 개체는 다음과 같아야 합니다.

Inspector 화면에서 위에서 아래로 Enemy 옆에 체크 표시가 있습니다. 태그 드롭다운에서 "플레이어"가 선택되고 레이어 드롭다운에서 "적"이 선택됩니다. 변환 드롭다운에는 위치, 회전 및 배율이 표시됩니다. Capsule 드롭다운 메뉴가 압축되어 있고 Mesh Renderer, Capsule Collider 및 Nav Mesh Agent 드롭다운이 왼쪽에 체크 표시가 있는 압축된 상태로 나타납니다. Enemy Sight Sensor 드롭다운에는 Script and Ignore Mask가 표시됩니다. PatrolPoints 드롭다운에는 스크립트와 4개의 PatrolPoint가 표시됩니다. 기본 상태 머신 그래프(스크립트) 드롭다운 옆에 확인 표시가 있습니다. 스크립트는 "BaseStateMachineGraph"를 표시하고 초기 상태는 "없음(기본 상태)"을 표시하고 그래프는 "EnemyGraph(FSM 그래프)"를 표시합니다. 마지막으로 Blue Enemy(Material) 드롭다운이 압축되고 아래에 "구성 요소 추가" 버튼이 나타납니다. 그것.
우리의 Enemy 게임 개체

그게 다야! 이제 그래픽 편집기가 있는 모듈식 FSM이 있습니다. 재생 버튼을 클릭하면 그래픽으로 생성된 적 AI가 이전에 생성한 ScriptableObject 적과 똑같이 작동한다는 것을 알 수 있습니다.

Forging Ahead: FSM 최적화

주의 사항: 게임을 위해 보다 정교한 AI를 개발함에 따라 상태 및 전환 수가 증가하고 FSM이 혼란스럽고 읽기 어려워집니다. 그래픽 편집기는 여러 상태에서 시작하여 여러 전환에서 끝나는 선의 웹과 유사하게 성장하며 그 반대의 경우도 마찬가지이므로 FSM을 디버그하기가 어렵습니다.

이전 자습서에서와 같이 코드를 직접 만들고, 스텔스 게임을 최적화하고, 이러한 문제를 해결하도록 초대합니다. 노드가 활성 또는 비활성인지 여부를 나타내기 위해 상태 노드를 색상으로 구분하거나 화면 공간을 제한하기 위해 RemainInStateInitial 노드의 크기를 조정하는 것이 얼마나 도움이 될지 상상해 보십시오.

이러한 향상은 단순히 미용적인 것이 아닙니다. 색상 및 크기 참조는 디버그할 위치와 시기를 식별하는 데 도움이 됩니다. 눈으로 보기 쉬운 그래프는 평가, 분석 및 이해하기도 더 쉽습니다. 다음 단계는 모두 사용자에게 달려 있습니다. 그래픽 편집기를 기반으로 하면 개발자 환경을 개선하는 데 제한이 없습니다.

Toptal 엔지니어링 블로그에 대한 추가 정보:

  • Unity 개발자가 저지르는 가장 일반적인 실수 10가지
  • MVC를 사용한 Unity: 게임 개발 수준을 높이는 방법
  • Unity에서 2D 카메라 마스터하기: 게임 개발자를 위한 튜토리얼
  • Toptal 개발자의 Unity 모범 사례 및 팁

Toptal 엔지니어링 블로그는 이 기사의 전문 지식과 기술 검토에 대해 Goran Lalic에게 감사를 표합니다.