흔한 덕후의 잡동사니

Behavior Tree를 서버에 넣기까지 본문

서버

Behavior Tree를 서버에 넣기까지

chinodaiski 2025. 4. 14. 04:15

BT를 서버에 이식하는 결정을 내린 계기

더보기

포폴로 C++서버와 Unity 클라이언트로 게임을 만들고 있다. 아직까진 MMORPG가 될지 MORPG가 될지 모르겠다.

우선 AI를 각 클라이언트에서 만들고, 서버에선 동기화만 하는 방식을 사용하려했는데, 충돌체 검증여부가 서버에 있고, 나중에 물리도 서버에서 만든것을 dll로 빼서 유니티에 이식알 예정이라 서버에서 AI에 대한 검증을 할 필요가 생겨버렸다.

애니메이션 제어라던지 UGS(UnityGoogleSheet)도 사용해볼겸 클라이언트에선 FSM형식으로 애니메이션 트리거만 제어하고, 피격 및 상태 제어는 서버에서 하는 방식이 제일 좋은 것으로 결론을 내리고 개발에 착수했다.

 

BT의 설계에 관해선 위키피디아에 있는 내용과 언리얼 엔진이 제공하고 있는 BT에 대한 문서를 바탕으로 진행했다.


Behavior Tree는 주로 인공지능(AI), 로보틱스, 게임 개발 분야에서 복잡한 행동을 계층적으로 표현하고 제어하기 위해 사용되는 모델이다.
BT는 트리 구조를 통해 복잡한 행동을 단순한 작업들로 분해하여, 각각의 작업을 순차적 또는 조건에 따라 실행할 수 있도록 하는 것이 기본 골자로 주요 개념은 다음과 같다.

노드(Node): 행동 트리의 기본 단위로, 각 노드는 하나의 행동을 수행하거나 조건을 검사
리프(Leaf) 노드: 실제 행동을 수행하거나 단순 조건을 확인하는 기본 노드
컴포지트(Composite) 노드: 여러 자식 노드를 가지며, 자식 노드의 실행 결과에 따라 행동을 결정
데코레이터(Decorator) 노드: 하나의 자식 노드를 감싸면서 실행 결과를 변환하거나 조건 검사를 추가

 

각 노드의 상태는 3가지, [ RUNNING, SUCCESS, FAILURE ] 로 해당 노드가 실행중인지, 성공적으로 끝났는지, 중간에 실패했는지를 나타낸다.

노드는 대표적으로 Sequence, Selector, Action, Condition이 있는데 각 노드에 대해서 설명하면 다음과 같다.

 

Sequence Node (순차 노드)
하위 노드를 순차적으로 실행하여, 모든 노드가 성공해야 전체가 성공하는 방식. 하나의 노드라도 실패하거나 실행 중이면 그 즉시 해당 상태를 반환. 작업의 필수 순서나 의존적인 행동에서 유용함

Selector Node (설렉터 노드)
자식 노드를 순서대로 실행하며, 하나라도 성공하면 전체가 성공하는 방식. 주로 비상 상황이나 대체 행동을 위해 사용됨

 

Action Node
Behavior Tree의 리프(leaf) 노드 중 하나로서, 실제로 특정 행동이나 기능을 실행하는 역할. 독립적인 동작을 수행하도록 설계되었으며, 예를 들어, 애니메이션 재생, 캐릭터 이동, 공격 등의 구체적인 행동을 처리한다.

Behavior Tree 이론에서는 복잡한 행동을 단순한 행동들로 분해하는 것이 핵심인데, Action Node는 이러한 단위 행동을 구현하며, 코드에서는 하나의 함수(액션 함수)를 호출하여 동작을 실행하고, 그 결과(성공, 실패, 혹은 실행 중)를 반환한다.


Condition Node
Action Node와 마찬가지로 Behavior Tree의 리프 노드로서, 주어진 조건을 평가하여 행동 트리의 흐름을 결정하는 역할. 간단한 논리 검사를 수행하고, 조건이 참이면 SUCCESS를, 거짓이면 FAILURE를 반환하여 상위 노드(예: Sequence, Selector)가 이후 노드의 실행 여부를 결정할 수 있도록 돕는다.
위키피디아에선 AI의 내부 상태나 외부 환경 요소(예: 플레이어 감지, 자원 확보 여부 등)를 판단하는 것으로 설명한다.

 

 

간단하게 말해서 여러 절차를 Sequence로 묶어 관리하고, Selector로 Sequence중 하나를 결정하여 최종 형태를 결정한다 정도로 보면 된다. 물론 Selector도 Sequence로 관리되는 단계중 하나가 될 수 있다. Action은 실제 행동을, Condition은 조건문이라 보면된다.

 

데코레이터는 조건식 안에 있는 반전(!)을 생각하면 된다. 조건 검사를 추가한다는 의미에서 &나 | 도 가능하지 않을까 생각했는데, 관련 예제를 찾을 수 없었기에 확실하진 않다.

컴포지트는 Selector, Sequence이고, Action, Condition을 리프라 생각하면 편하다.

 

 

문제 발생 및 해결

이렇게 틀을 잡고 작업을 하던 도중 문제가 하나 생겼다. 유니티로부터 애니메이션 정보를 가져와서 처리하는데 기획자 분께서 상황에 따라 행동이 취소될 수 있다는 것이다. (예를 들어 공격 패턴이 나오다가 체력이 일정 이하가되면 현재 패턴을 취소하고 다음으로 넘어가야했다.) 기존 구조는 특정 노드가 실행되면 끝날 때 까진 계속 실행되는 구조로 작성되고 있었는데, 이게 아닌 행동을 중간에 멈출 수 있는 기능을 추가해야했다.

고민 끝에 중간에 행동을 바꿀 수 있는 Node를 만들어서 처리하기로 했다. 이전 액션 상태를 기록해놨다가 [ 조건, 참인 경우 액션, 거짓인 경우 액션 ]을 인자로 받아 매 검사시 조건을 먼저 검사하고, 액션을 이어나가는 것이다.

이런 식으로 액션을 만드니 나중에 추가된 조건인 [ 어떤 애니메이션은 최소한 어떤 프레임까진 실행이 되어야할 수 있다 ]는 조건을 쉽게 만들 수 있었다.

 

 

코드 작성

AI를 만들기 위해 필요한 정보를 크게 5가지로 나누었다.

첫째, AI에 대한 정보를 담기 위한 AIContext. 이는 UE에서 BlackBoard라 부른다.

둘째, BehaviourTree 노드

셋째, AI 객체. AIEntity라 정의했다. 각 Entitty는 고유의 id를 들고 있다.

넷째, 서버에서 AI를 관장하는 AIManager. 나중에 Tick을 돌리는 부분만 멀티스레드로 뺄 예정이다. 현재는 매 프레임 recv한 패킷을 모두 처리하고 나서 싱글로 이어서 돌리고 있다.

다섯째, 행동을 정의할 스크립트. 각 객체별로 별도의 스크립트를 작성하여 사용했다.

 

AIContext

더보기
// 언리얼에서 blackboard라 불리는 AI 정보를 컨트롤하는 구조체
struct AIContext {
    bool bDeath = false;          // 체력이 0이면 true가 됨

    float distanceToPlayer;       // AI와 플레이어 사이의 거리
    UINT32 health;                // 현재 체력
    UINT32 prevHealth;            // 이전 틱의 체력 (공격 중 피격 여부 판단에 사용)

    float attackRange = 5.0f;     // 공격 가능 범위
    float moveSpeed = 0.8f;       // 매 틱당 이동 속도
    float detectionRange = 15.0f; // 플레이어 감지 범위
};

AIManager

더보기
// 나중에 UpdateAll을 멀티스레드로 뺄 예정. 이건 테스트하면서 [ 스레드 갯수, AI 갯수 ]를 조건으로 가장 성능이 좋은 값을 찾아야함.
class AIManager {
public:
    ~AIManager() {
        for (AIEntity* entity : m_entities) {
            delete entity;
        }
        m_entities.clear();
        m_idToIndex.clear();
    }

    // 새로운 AI를 추가하고, id와 인덱스를 보조 인덱스에 등록
    void AddEntity(AIEntity* entity) {
        int id = entity->GetID();
        m_entities.push_back(entity);
        m_idToIndex[id] = m_entities.size() - 1;
    }

    // id를 통해 AIEntity를 빠르게 찾는 함수
    AIEntity* FindEntityByID(int id) {
        auto it = m_idToIndex.find(id);
        if (it != m_idToIndex.end()) {
            size_t index = it->second;
            return m_entities[index];
        }
        return nullptr;
    }

    // 모든 AI를 업데이트하고, 죽은 AI는 swap-and-pop 기법으로 리스트에서 제거
    void UpdateAll() {
        for (size_t i = 0; i < m_entities.size(); ) {
            AIEntity* ai = m_entities[i];
            ai->Update();
            if (!ai->IsAlive()) {
                std::cout << "[AIManager] Removing dead AI (ID: " << ai->GetID() << ")\n";
                int deadId = ai->GetID();
                delete ai;

                size_t lastIndex = m_entities.size() - 1;
                if (i != lastIndex) {
                    // 마지막 요소를 현재 요소와 교환
                    AIEntity* swappedEntity = m_entities.back();
                    m_entities[i] = swappedEntity;
                    // 교환된 요소의 index를 업데이트
                    m_idToIndex[swappedEntity->GetID()] = i;
                }
                // 마지막 요소를 제거하고, 보조 인덱스에서도 제거
                m_entities.pop_back();
                m_idToIndex.erase(deadId);
                // 교환된 요소가 i번째에 들어왔으므로, i는 증가시키지 않음
            }
            else {
                ++i;
            }
        }
    }

private:
    std::vector<AIEntity*> m_entities;
    std::unordered_map<int, size_t> m_idToIndex;
};

AIEntity

더보기
using BTBuilder = std::function<BTNode* (AIContext&)>;

class AIEntity {
public:
    AIEntity(const AIContext& aiContext, BTBuilder builder)
    {
        m_id = g_id++;
        m_context = aiContext;
        // 전달받은 builder 함수를 사용하여 행동 트리 생성
        m_behaviorTree = builder(m_context);
    }

    ~AIEntity() {
        delete m_behaviorTree;
    }

    // 매 틱마다 Behavior Tree 업데이트와 시뮬레이션용 데미지 처리를 진행합니다.
    void Update() {
        NodeStatus status = m_behaviorTree->Tick();
        if (status != NodeStatus::RUNNING)
            m_behaviorTree->Initialize();

        // [시뮬레이션] 플레이어가 감지되면 매 틱마다 데미지 입음
        if (m_context.distanceToPlayer <= m_context.detectionRange) {
            m_context.health -= 1;
            std::cout << "[Simulation] AI " << m_id
                << " 데미지 입음. 현재 체력: " << m_context.health << "\n";
            // 체력이 0 이하이면 사망 상태 설정
            if (m_context.health <= 0)
                m_context.bDeath = true;
        }
    }

    bool IsAlive() const {
        return (m_context.health > 0);
    }

    int GetID() const { return m_id; }

private:
    int m_id;
    AIContext m_context;
    BTNode* m_behaviorTree;

    static UINT32 g_id;
};

BT

더보기
// 노드 실행 상태 열거형
enum class NodeStatus { RUNNING, SUCCESS, FAILURE };

// BT 노드 기본 클래스
class BTNode {
public:
    virtual ~BTNode() {}
    virtual NodeStatus Tick() = 0;
    virtual void Initialize() {}
};

// 시퀀스 노드: 자식 노드를 순차적으로 실행
class SequenceNode : public BTNode {
public:
    SequenceNode(const std::vector<BTNode*>& children)
        : m_children(children), m_currentChildIndex(0) {}

    virtual ~SequenceNode() {
        for (BTNode* child : m_children) {
            delete child;
        }
        m_children.clear();
    }

    virtual void Initialize() override {
        m_currentChildIndex = 0;
        for (BTNode* child : m_children) {
            child->Initialize();
        }
    }

    virtual NodeStatus Tick() override {
        while (m_currentChildIndex < m_children.size()) {
            NodeStatus status = m_children[m_currentChildIndex]->Tick();
            if (status == NodeStatus::RUNNING)
                return NodeStatus::RUNNING;
            else if (status == NodeStatus::FAILURE)
                return NodeStatus::FAILURE;
            m_currentChildIndex++;
        }
        return NodeStatus::SUCCESS;
    }

private:
    std::vector<BTNode*> m_children;
    size_t m_currentChildIndex;
};

// 설렉터 노드: 자식 노드를 차례대로 실행하여, 하나라도 성공하면 성공을 반환
class SelectorNode : public BTNode {
public:
    SelectorNode(const std::vector<BTNode*>& children)
        : m_children(children), m_currentChildIndex(0) {}

    virtual ~SelectorNode() {
        for (BTNode* child : m_children) {
            delete child;
        }
        m_children.clear();
    }

    virtual void Initialize() override {
        m_currentChildIndex = 0;
        for (BTNode* child : m_children) {
            child->Initialize();
        }
    }

    virtual NodeStatus Tick() override {
        while (m_currentChildIndex < m_children.size()) {
            NodeStatus status = m_children[m_currentChildIndex]->Tick();
            if (status == NodeStatus::RUNNING)
                return NodeStatus::RUNNING;
            else if (status == NodeStatus::SUCCESS)
                return NodeStatus::SUCCESS;
            m_currentChildIndex++;
        }
        return NodeStatus::FAILURE;
    }

private:
    std::vector<BTNode*> m_children;
    size_t m_currentChildIndex;
};

// 조건 노드: 조건 함수의 결과에 따라 성공 또는 실패를 반환
class ConditionNode : public BTNode {
public:
    explicit ConditionNode(std::function<bool()> conditionFunc)
        : m_conditionFunc(conditionFunc) {}

    virtual NodeStatus Tick() override {
        bool result = m_conditionFunc();
        return result ? NodeStatus::SUCCESS : NodeStatus::FAILURE;
    }
private:
    std::function<bool()> m_conditionFunc;
};

// 행동 노드: 실제 행동을 담당하는 노드
class ActionNode : public BTNode {
public:
    explicit ActionNode(std::function<NodeStatus()> actionFunc)
        : m_actionFunc(actionFunc) {}

    virtual NodeStatus Tick() override {
        return m_actionFunc();
    }
private:
    std::function<NodeStatus()> m_actionFunc;
};

// 동적 액션 노드: 매 Tick마다 조건을 확인하여 기본 액션과 대체 액션 중 하나를 실행
// 이 노드를 사용하면 액션 실행 도중 조건에 따라 즉시 다른 행동으로 전환할 수 있음
class DynamicActionNode : public BTNode {
public:
    // @param conditionFunc: 조건 확인 함수 (true이면 대체 액션 실행)
    // @param primaryActionFunc: 기본 동작 액션 함수
    // @param alternativeActionFunc: 조건 충족 시 실행될 대체 액션 함수
    DynamicActionNode(std::function<bool()> conditionFunc,
        std::function<NodeStatus()> primaryActionFunc,
        std::function<NodeStatus()> alternativeActionFunc)
        : m_conditionFunc(conditionFunc),
        m_primaryActionFunc(primaryActionFunc),
        m_alternativeActionFunc(alternativeActionFunc) {}

    virtual NodeStatus Tick() override {
        // 매 Tick마다 조건을 확인하여, 조건이 충족되면 대체 액션을 실행
        if (m_conditionFunc()) {
            return m_alternativeActionFunc();
        }
        return m_primaryActionFunc();
    }

private:
    std::function<bool()> m_conditionFunc;
    std::function<NodeStatus()> m_primaryActionFunc;
    std::function<NodeStatus()> m_alternativeActionFunc;
};

 

이렇게 구성하니 필요한 기능이 있다면 노드만 추가해서 사용할 수 있었다. 

 

 

아쉬운점

몇가지 아쉬운 점이 있다.

첫째, 서버에서 작성한 코드를 유니티에서 그대로 사용하지 못한다는 것이다. 처음에 유니티에서 작성한 ai를 그대로 사용하려 했던것이 이런 이유였는데 아쉽지만 행동은 서버에서, 유니티에선 행동에 따른 애니메이션 제어를 추가로 해야했다.

둘째, 여러 행동을 병행해서 하지 못한다는 것이다. UE 문서를 찾아보니 여러 노드가 병렬로 실행될 수 있는, 이펙트 등을 결정하는 방식이 있다고 하는데, 이건 구현이 어려울 듯하다. 뭔가 방법이 있을 것 같은데...

셋째, 여긴 예시를 작성하지 않았지만 스크립트를 작성함에 있어 모든 함수를 람다로 표현했다. 이게 코드를 작성하고, 수정함에 있어 편리하니 그랬다. 하지만 성능적 문제로 넘어가자면 문제가 생긴다. 재활용이 안되니 같은 기능을 가진 함수가 추가로 만들어질 것이지만 그렇다고 모든 함수를 기능별로 따로 만들어서 관리한다? 이게 유지보수에 있어 문제가 심하지 않을까한다. 병목은 나중에 멀티스레드로 Tick을 나누어 처리하는 것으로 시간을 단축함으로서 극복해보자.

 

TMI

더보기

+ 넷째로 스크립트를 작성하고 나니 한눈에 들어오지 않는다는 문제가 있었는데, 놀랍게도 GPT를 돌리니 스크립트의 내용을 한눈에 알아보기 쉽게 단축해줬다. 기술의 발전이란... 보다 적극적으로 활용해야겠다는 생각과 동시에 그럴싸한 말을 이상하게 하는 부분이 있어 사용에 있어 주의해야겠다는 생각이 강해졌다.