ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Unity] Behaviour Tree에 관한 간단한 이야기
    쾌락없는 책임 (공부)/Unity 2023. 2. 9. 17:37
    반응형

    개요

    지금까지는 FSM를 통해서 간단한 AI들만 만들어 왔습니다. 다만 FSM은 확실히 한계가 있고 유지보수에 있어서 코드를 보는 게 많이 힘들었습니다. 그래서 BT라는 게 있다! 는 걸 알고는 있었는데... 뭔가 알 수 없는 벽을 느껴 일단 외면하고 있었습니다.

    최근에 와서 더 많은걸 알아봐야 된다는 필요성을 느끼고 있었고 이에 따라서 BT와 관련한 것들을 알아보고 정리하게 되었습니다. 일단 유니티의 경우에는 Behaviour Tree를 직접 구현하거나 에셋을 활용해야 하는데 일단 직접 구현으로 시작하게 되었습니다.

     

     

    Behaviour Tree 에 관한 간단한 개요

    일단 FSM을 간단하게 알아보고 가겠습니다.

    전에 만든 간단한 FSM 입니다. 객체는 한 개의 State만 가질 수 있고 각 State는 다른 State로 갈 수 있는 전이(Transition)를 가지고 있습니다. 주로 네트워크 관련 자료를 보거나 유니티 애니메이션을 관리하다 보면 이를 볼 수 있게 됩니다.

     

    다만 FSM은 State가 많아지고 전이가 많아지면 점점 복잡해지고 비주얼적으로도, 코드적으로도 알아보기 힘들다는 단점이 있습니다. 이를 개선해서 여러 State들을 그룹화하는 HFSM 같은 개념도 있지만 결국 FSM이라는 개념에서 벗어나지 못합니다.

     

    사진 출처 : Wikipedia

    반면 Behaviour Tree(이하 BT로 표기)는 위 그림처럼 트리로 나타내지기에 더 알아보기 좋습니다. 또한 각 동작을 하는 노드를 재사용하기도 쉽습니다. 무엇보다 각 노드가 전이에 대한 생각을 하지 않아 더 명확한 의미 전달이 되는 장점도 있습니다.

     

     

    BT의 요소

    일단 BT는 트리의 형태를 하고 있으며 아래 요소들을 포함하고 있습니다.

    • 루트(root)
    • 흐름 제어 노드 (flow-control node)
      • Sequence node : and 역할을 하는 노드
      • Selector node : or 역할을 하는 노드
    • 리프 노드(leaf node, action node 라고도 함) : 실제 행동이 들어가 있는 노드

    루트에서 여러 흐름제어 노드를 타고 내려가 최종적으로 리프 노드에 도달하는 형태입니다. 때문에 트리의 리프 노드는 항상 Action을 담고 있어야 하고 루트와 리프를 제외하고는 전부 흐름 제어 노드들이 차지하게 됩니다.

     

    흐름 제어 노드들의 역할을 자세히 보기 전 각 노드의 상태를 알아야 합니다. 각 노드는 3가지 상태중 하나를 가질 수 있으며 그 목록은 아래와 같습니다.

    • Success 
    • Failure
    • Running

    말 그대로 각 노드들의 상태를 이야기 하는 것입니다. 그리고 이에 따라서 흐름 제어 노드들이 반환하는 결과값이 달라지게 됩니다.

    • Sequence node
      • and 역할
      • 하나라도 Failure라면 Failure 반환
      • 아니라면 Running, Success중 반환 (Running이 우선순위 높음)
    • Selector node
      • or 역할
      • 하나라도 Running이거나 Success라면 바로 반환

    그리고 각 노드의 경우 'Evaluate()' 와 같은 함수를 가지고 있어 이 함수에서 각 노드의 행동을 하고 상태를 반환하게 됩니다. Action 노드의 경우에는 여기에서 행동을 하고 return Running 같은 일을 하게 되는 것이죠.

    말을 잘 못해서 그런데 아마 이후 코드를 보고 나면 이해될 거라 봅니다.

     

    이런 요소들을 통해서 BT를 구성하게 됩니다. 소스코드로 보는 노드들은 아래 [더보기]를 누르면 알 수 있습니다.

    더보기

    Node.cs

    public enum NodeState
    {
        Running,
        Failure,
        Success
    }
    
    public abstract class Node
    {
        protected NodeState state;
        public Node parentNode;
        protected List<Node> childrenNode = new List<Node>();
    
        public Node()
        {
            parentNode = null;
        }
    
        public Node(List<Node> children)
        {
            foreach(var child in children)
            {
                AttatchChild(child);
            }
        }
    
        public void AttatchChild(Node child)
        {
            childrenNode.Add(child);
            child.parentNode = this;
        }
    
        public abstract NodeState Evaluate();
    }

    SequenceNode.cs

    public class SequenceNode : Node
    {
        public SequenceNode() : base() {}
    
        public SequenceNode(List<Node> children) : base(children) {}
    
        public override NodeState Evaluate()
        {
            bool bNowRunning = false;
            foreach (Node node in childrenNode)
            {
                switch (node.Evaluate())
                {
                    case NodeState.Failure:
                        return state = NodeState.Failure;
                    case NodeState.Success:
                        continue;
                    case NodeState.Running:
                        bNowRunning = true;
                        continue;
                    default:
                        continue;
                }
            }
    
            return state = bNowRunning ? NodeState.Running : NodeState.Success;
        }
    }

    SelectorNode.cs

    public class SelectorNode : Node
    {
        public SelectorNode() : base(){}
    
        public SelectorNode(List<Node> children) : base(children){}
    
        public override NodeState Evaluate()
        {
            foreach(Node node in childrenNode)
            {
                switch(node.Evaluate())
                {
                    case NodeState.Failure:
                        continue;
                    case NodeState.Success:
                        return state = NodeState.Success;
                    case NodeState.Running:
                        return state = NodeState.Running;
                    default:
                        continue;
                }
            }
    
            return state = NodeState.Failure;
        }
    }

     

    각 노드를 관리하는 트리의 경우 root를 가진 채 유니티 Update에서 각 노드를 평가해주면 됩니다. Evaluate 함수를 불러주면 되는 것이죠.

    public abstract class Tree : MonoBehaviour
    {
        private Node rootNode;
    
        protected void Start()
        {
            rootNode = SetupBehaviorTree();
        }
    
        protected void Update()
        {
            if(rootNode is null)    return;
            rootNode.Evaluate();
        }
    
        protected abstract Node SetupBehaviorTree();
    }

     

     

     

    간단한 BT 예시로 코드 사용법 익히기

    간단한 예시를 만드는것 만큼 익숙해지기 쉬운 건 없다고 생각합니다. 일단 먼저 최근 제작하고 있는 게임에서 '펫'을 하나 제작하고 있습니다. 단순히 플레이어를 따라다니는 펫인데 뭐 간단한 예시가 될 것 같아 이걸 BT로 만들어 보기로 했습니다.

    아마 BT로 이를 만들어 본다면 위와 같은 모습이 될 것입니다. 루트 노드에서 Selector 노드로 간 후 Sequence 노드를 먼저 보게 됩니다. 만일 플레이어가 근처에 있다면 기다리는 모션을 취하고 근처에 없다면 따라가는 행동을 취할 것입니다.

    [순간 헷갈려서 스스로 정리]
    위와 같은 원리가 되는 이유는 '플레이어 근처인가?' 노드에서 만일 플레이어 근처다!라고 결정이 나면 Success를 반환하게 되며 Sequence는 and 역할을 하니 '플레이어 근처에서 기다리기' 노드도 보게 됩니다. 그러고 나서 Selector 노드의 경우 이미 Success(or Running)이 반환되었으므로 or의 역할에 따라 무조건 Success를 반환하고 다음 노드인 '플레이어 따라가기' 노드는 따로 검사하지 않게 됩니다.

     

    그러면 각 리프 노드는 '플레이어 근처인가?', '플레이어 근처에서 기다리기', '플레이어 따라가기' 노드입니다. 이것드에 대한 행동들을 간단하게 정의해 보겠습니다.

     

    ['플레이어 근처인가?'를 보는 CheckPlayerIsNear.cs]

    public class CheckPlayerIsNearNode : Node
    {
        private static int playerLayerMask = 1 << 6;
        private Transform transform;
        private Animator anim;
    
        public CheckPlayerIsNear(Transform transform)
        {
            this.transform = transform;
            anim = transform.GetComponent<Animator>();
        }
    
        public override NodeState Evaluate()
        {
            var collider = Physics.OverlapSphere(transform.position, 5.0f, playerLayerMask);
            if(collider.Length <= 0)    return state = NodeState.Failure;
    
            anim.SetBool("Following", false);
            return state = NodeState.Success;
        }
    }

    [플레이어 근처에서 기다리기를 하는 StayNearPlayer.cs]

    public class StayNearPlayerNode : Node
    {
        private Animator anim;
    
        public StayNearPlayerNode(Transform transform)
        {
            anim = transform.GetComponent<Animator>();
        }
    
        public override NodeState Evaluate()
        {
            anim.SetBool("Following", false);
    
            return state = NodeState.Running;
        }
    }

    [플레이어에게 다가가는 GoToPlayerNode.cs]

    public class GoToPlayerNode : Node
    {
        private Transform player;
        private Transform transform;
        private Animator anim;
    
        public GoToPlayerNode(Transform player, Transform transform)
        {
            this.player = player;
            this.transform = transform;
            anim = transform.GetComponent<Animator>();
        }
    
        public override NodeState Evaluate()
        {
            transform.LookAt(player);
            transform.position = Vector3.Lerp(transform.position, player.position, Time.deltaTime);
            anim.SetBool("Following", true);
    
            return state = NodeState.Running;
        }
    }

     

    그런 다음 이 펫의 BT를 정리해 둬야겠지요. 아래와 같은 모습으로 정리하면 됩니다. 이 경우 List에서 트리로 구성을 해야 하므로 꼭 순서에 유의해서 넣어줘야 합니다!

    public class FollowingPlayerPetBT : AG.GameLogic.BehaviorTree.Tree //UnityEngine.Tree와 겹쳐서..
    {
        [SerializeField]
        private Transform player;
        [SerializeField]
        private Transform pet;
    
        protected override Node SetupBehaviorTree()
        {
            Node root = new SelectorNode(new List<Node>
            {
                new SequenceNode(new List<Node>
                {
                    new CheckPlayerIsNear(pet),
                    new StayNearPlayerNode(pet)
                }),
                new GoToPlayerNode(player, pet)
            });
            return root;
        }
    }

    이후 완성된 펫의 BT 입니다. 이렇게 하면 플레이어를 잘 따라다니게 됩니다!

    정말 따라다니기만 합니다

     

     

    추가 글

    사실 정말 간단한 BT의 이론과 예시이고 실제 BT는 더 다양한 노드들이 있습니다. 예시로 Decorator라는 노드가 있어 if 문과 같은 역할을 하는 노드도 있습니다. 하지만 이번 글에서는 아직 저의 숙련도가 높지 않아 한번에 담지 못했습니다. 또한 이걸 비주얼화 하지 않았으므로 사실 FSM에 비해서 보기 좋다! 하기에는 무리가 조금 있습니다. 다만 확장성은 어느 정도 갖추게 된 것이죠.

     

    그 외 언리얼의 BT를 조금 알아보니 언리얼에서는 이미 BT가 내장되어 있고 여러가지 추가점이 있었습니다. 이벤트 드리븐 BT라 Update/Tick 등에서 이를 계속 체크할 이유가 없어 더 효율적이고 데코레이터도 있다고 합니다. 이와 관련한 것들은 추후 UE5를 더 공부하면서 적 AI를 만들 때 공부한 뒤 정리되는 대로 적어보도록 하겠습니다.

     

     

    참고 글

     

    Behavior Tree를 알아봅시다

    안녕하세요. 저는 Clova를 구성하는 시스템 가운데 NLU(Natural Language Understanding,자연어 이해)파트의 서버쪽 개발을 담당하고 있는 @overlast입니다. 얼마전에 Youichiro Miyake(三宅陽一郎)씨와 대담(일본

    engineering.linecorp.com

     

    비헤이비어 트리 개요

    언리얼 엔진의 비헤이비어 트리 개념과 전통적인 비헤이비어 트리와의 차이점에 대해 설명합니다.

    docs.unrealengine.com

     

    반응형

    댓글

Designed by Tistory.