GOBT: Goal-Oriented Behavior Tree
Behavior Tree의 구조적 직관성과 GOAP·Utility Theory의 동적 유연성을 결합한 하이브리드 AI 프레임워크
개요 (Overview)
본 프로젝트는 행동 트리(BT)의 구조적 명확성과 목표 지향 액션 플래닝(GOAP)의 상황 적응성을 융합한 Unity 기반 계층형 AI 프레임워크 입니다. 표준 BT 내에 GOAP과 유틸리티 이론을 접목한 커스텀 알고리즘 노드를 통합함으로써, 기존 NPC AI의 정적인 의사결정 한계를 극복하고자 했습니다. 상위 수준의 흐름 제어는 BT가 담당하고, 복잡한 상황 판단은 커스텀 노드에 위임하는 계층적 설계를 통해 연산 효율성과 개발 편의성을 동시에 확보했습니다.


1 계층: Behavior Tree
상위 수준의 의사결정(전략)을 담당합니다. 커스텀 노드인 Planner Node를 통해 하위 수준의 의사결정 분기로 진입할 수 있습니다.
2 계층: Planner Node
하위 수준의 의사결정(전술)을 담당합니다. 내부는 상태 그래프로 구성되어 있으며, 각 상태에는 실행할 수 있는 Action이 존재합니다. 그래프에는 시작 상태와 목표 상태가 존재하는데, Planner Node는 목표 상태에 도달하기 위한 Action 체인을 제공합니다.
3 계층: Utility Calculator
환경 반응 및 상태 전환을 담당합니다. 상태 전환에 경합이 발생할 경우 현재 게임 환경을 정규화된 유틸리티 수치로 변환하여 경합하는 상태에 대입 후 더 높은 값을 반환하는 상태로 전환합니다.

Fig 1. 3계층 프레임워크 구조
기술 스택 (Teck Stak)
| Category | Technologies |
|---|---|
| Game Engine | Unity 3D |
| AI Architecture | Behavior Tree, GOAP (Goal-Oriented Action Planning), Utility System |
| Tools/Assets | Behavior Designer |
| Language | C# |
주요 기능 (Key Features)
1. 플래너 노드를 통한 데이터 주도형 상태 전이
- Enum 비트마스크 기반 월드 상태 표현: 에이전트 상태와 환경 데이터를 Enum 기반 비트마스크로 인코딩했습니다. HasWeapon, LowHealth 등의 조건을 비트 플래그로 관리하여, 조건 일치 여부를 문자열 비교나 딕셔너리 조회 없이 빠른 비트 연산(AND/OR)만으로 판별합니다. WorldState 구조체는 불변 값 타입(Immutable Value Type)으로 설계되어 비교 시 힙 할당(Heap Allocation)이 발생하지 않도록 했습니다.
[Flags]
public enum WorldStateFlags : uint
{
None = 0,
EnemyVisible = 1 << 0,
EnemyInRange = 1 << 1,
LowHealth = 1 << 2,
HasWeapon = 1 << 3,
IsBlocking = 1 << 4,
EnemyDead = 1 << 5,
InCover = 1 << 6,
IsExhausted = 1 << 7,
HealthRestored = 1 << 8,
}
// Immutable value-type wrapper — zero GC allocation during comparisons
public readonly struct WorldState
{
public readonly WorldStateFlags Flags;
public bool Has(WorldStateFlags f) => (Flags & f) == f;
public WorldState With(WorldStateFlags f) => new(Flags | f);
public WorldState Without(WorldStateFlags f) => new(Flags & ~f);
public bool Satisfies(WorldStateFlags goal) => (Flags & goal) == goal;
public uint Key => (uint)Flags; // O(1) key for visited-set / cache lookups
}
전/후 조건 기반 상태 탐색: 에이전트의 현재 월드 상태와 다음 가능한 상태의 사전조건(Pre-condition) 마스크를 비교하여 상태를 동적으로 전이합니다. 각 상태에 사전조건과 사후효과(After-effect)만 명시하면 시스템이 목표 도달을 위한 그래프를 자동 생성하므로, 하드코딩된 전이 로직이 필요 없는 데이터 주도형 구조를 실현했습니다.
유틸리티 기반 상태 평가: 상태 전이 중 경합이 발생할 경우 유틸리티 평가기에 의해 더 높은 가치를 반환하는 상태로 전이합니다.
2. 유틸리티 기반 실시간 의사결정 최적화
다변수 데이터 정규화: 체력, 거리, 레벨 등 서로 다른 단위의 환경 변수를 0.0~1.0 범위로 정규화하여 판단의 객관성을 확보했습니다. 이를 통해 비선형적인 전장 상황을 수치화된 가치로 변환합니다.
최적 기대 가치 산출: 단순히 고정된 우선순위를 따르지 않고, 가중치($w$)가 적용된 유틸리티 함수를 실시간 평가하여 기회비용이 가장 낮고 기대 가치가 높은 행동을 선택합니다.



3. 모듈화된 Authoring Tool 및 확장성
Decoupled Architecture: 런타임 탐색 로직(Planner)과 개별 행동 로직(Action)을 완전히 분리했습니다. 새로운 행동 추가 시 기존 코드를 수정할 필요 없이 독립적인 액션 노드만 추가하면 되는 Open-Closed Principle을 준수합니다.
ScriptableObject 기반 모듈 조합: 액션과 유틸리티 함수를 에셋화하여 인스펙터 상에서 액션의 사전/사후 조건을 설정하고 게임 로직과 연결할 수 있습니다.
인터페이스 기반 확장성 (OCP 준수): 플래너 런타임과 개별 액션 로직을 추상 클래스로 엄격히 분리했습니다. 새로운 액션 타입을 추가할 때 플래너 수정 없이 인터페이스만 구현하면 되므로, 개방-폐쇄 원칙(Open-Closed Principle) 을 충족합니다.
// Abstract base — one method contract, zero runtime coupling
public abstract class GOBTEvaluatorSO : ScriptableObject
{
/// <returns>Utility score in [0, 1]. 1 = maximally desirable.</returns>
public abstract float Evaluate(AgentController agent);
}
// Example: HealthEvaluatorSO, DistanceEvaluatorSO, ThreatEvaluatorSO,
// and CompositeEvaluatorSO (nestable weighted sum of child evaluators)
// are all authored as .asset files and assigned in the Inspector.



기술적 난제 및 해결 전략 (Problem Solbing)
1. 다중 에이전트 연산 병목 해결 (Time-slicing)
- Problem: 수십 명의 에이전트가 동시에 그래프를 구축할 때 발생하는 CPU Spike 현상 확인.
- Solution: 단일 프레임의 과도한 연산을 방지하기 위해 코루틴 기반 시분할 처리(Time-slicing) 기법을 도입. 프레임당 가용 연산 시간을 초과할 경우 작업을 다음 프레임으로 이월.
- Result: 초기화 시 발생하는 프레임 저하를 80% 이상 개선, 대규모 유닛 환경에서도 안정적인 프레임 유지.
public IEnumerator PlanCoroutine(PlanRequest request)
{
float frameStart = Time.realtimeSinceStartup;
while (_openList.Count > 0)
{
// Yield to next frame if this frame's budget is exceeded
if (Time.realtimeSinceStartup - frameStart > FrameBudgetMs * 0.001f)
{
yield return null; // resume next frame
frameStart = Time.realtimeSinceStartup;
}
var current = _openList[0];
_openList.RemoveAt(0);
if (current.State.Satisfies(request.GoalFlags))
{
BuildPlan(current, request.ResultPlan);
FinalizeRequest(request, succeeded: true);
yield break;
}
// ... expand neighbours
}
}


2. 그래프 내 갇힘 문제
- Problem: 동적인 환경 변화에 의해 상태 그래프 내에서 목표 상태에 도달하지 못하고 동일한 상태를 순환하여 목표 달성에 실패하는 교착 현상이 확인.
- Solution: 탐색 알고리즘에 방문 노드 리스트(Visited List) 와 최대 탐색 깊이(Max Depth) 제한을 도입했습니다. 또한, 동일 행동 반복 시 유틸리티에 페널티를 부여하는 ‘스티키니스(Stickiness)’ 개념을 적용하여 강제로 다른 대안 노드를 탐색하도록 유도.
- Result: 예외적인 환경 변화 상황에서도 에이전트가 교착 상태에 빠지지 않고 대안 루트를 탐색하거나 상위 목표로 복귀하는 등 의사결정의 안정성을 확보.
// Inside PlanCoroutine — Visited List prevents revisiting identical world states
uint key = next.Key;
if (_visited.Contains(key)) continue;
_visited.Add(key);
// Stickiness: inflate cost when the same action repeats consecutively
float cost = action.Cost;
if (current.LastAction != null && current.LastAction == action)
cost *= StickinessMultiplier; // default 3×
// Inside GOBTEvaluationEngine — RepeatPenalty at the evaluation layer
float weighted = action.UtilityWeight * Normalize.Clamp01(rawScore);
if (action == _lastAction)
weighted *= RepeatPenalty; // default 0.55×
3. 유틸리티 계산 비용 최적화 (Weight Caching)
- Problem: 상태 전환 시마다 모든 하위 노드의 유틸리티 가중치를 실시간으로 계산하는 방식은 에이전트 수에 비례하여 CPU 연산 부하를 가중.
- Solution: 유틸리티 값에 영향을 주는 핵심 변수(체력, 거리 등)가 일정 임계값 이상 변하지 않았을 경우, 이전 계산값을 재사용하는 캐싱 매커니즘을 도입했습니다. 또한, 업데이트 주기를 에이전트별로 엇갈리게 배치하는 틱(Tick) 시스템을 병행.
- Result: 유틸리티 계산 빈도를 효율적으로 조절함으로써, 연산 비용을 최적화하고 더 많은 수의 에이전트를 한 화면에 배치할 수 있는 성능적 여유를 확보.
// GOBTEvaluationEngine — cache keyed on the WorldStateFlags bitmask
public IReadOnlyList<ScoredAction> Score(AgentController agent,
IReadOnlyList<GOBTActionData> actions, WorldStateFlags currentStateFlags)
{
// Cache hit: world state unchanged → skip all evaluator calls
if (currentStateFlags == _cachedStateKey && _cachedScores.Count > 0)
return _cachedScores;
_cachedStateKey = currentStateFlags;
_cachedScores.Clear();
// ... evaluate and sort actions
}
// GOBTTickManager — only 1/N agents replan per frame (N = TickGroups)
private void Update()
{
int bucket = _frameCount % _tickGroups;
for (int i = 0; i < _agents.Count; i++)
if (i % _tickGroups == bucket)
_agents[i].RequestReplan();
_frameCount++;
}
4. 오브젝트 풀링을 통한 GC 스파이크 방지
Problem: 상태 그래프 목표 탐색 과정에서 수많은 ActionNode 인스턴스가 생성/소멸되어 빈번한 가비지 컬렉션(GC) 동작 유발.
Solution: 시작 시 미리 할당된 ActionNode Pool 을 도입. 노드를 렌트하여 사용 후 탐색이 완료되면 일괄 반납하는 구조로 설계하여, 플래닝 시 발생하는 힙 할당을 거의 제로(Zero-allocation)에 가깝게 유지.
Result: GC로 인한 프레임 끊김 현상을 제거하여 실시간 전투 시나리오에서의 실용성을 향상.
// Pre-allocated at planner construction — no runtime new() for StateNode
private readonly ActionNodePool _nodePool = new(256);
// Rent a node from the pool; create overflow only if pool is exhausted
public ActionNode Rent()
{
if (_pool.Count > 0) return _pool.Pop();
#if UNITY_EDITOR
Debug.LogWarning("[StateNodePool] Pool exhausted — allocating overflow node.");
#endif
return new ActionNode();
}
// After planning: return all rented nodes in one pass, clearing the list
public void ReturnAll(List<ActionNode> nodes)
{
foreach (var n in nodes) { n.Reset(); _pool.Push(n); }
nodes.Clear();
}
// Usage in PlanCoroutine
var child = _nodePool.Rent();
_allRented.Add(child);
// ... populate child ...
// After plan resolves:
_nodePool.ReturnAll(_allRented);
결과 (Results)
- Journal of Multimedia Information System (JMIS) 2023년 10월호 4권 ‘GOBT: A Synergistic Approach to Game AI Using Goal-Oriented and Utility-Based Planning in Behavior Trees’ 제 1저자 투고 및 출판 완료.
- 역할: 메인 프로그래머, 논문 작성