Hits



GOAP 란 무엇인가?

  • 목표 지향 행동 계획 (Goal Oriented Action Planning)는 게임 인공지능(AI)에서 에이전트가 자율적으로 행동을 결정하고 게임 환경 내에서 특정 목표를 달성할 수 있도록 하는 기술이다.

  • GOAP는 게임의 비플레이어 캐릭터(NPC)에 복잡하고 적응적인 행동을 부여하는 데 사용되며 NPC가 자율적으로 행동을 결정하고 목표를 달성할 수 있도록 하는 기술이다.

  • 복잡한 목표를 더 작은 행동으로 나누고, 이를 조합하여 목표에 도달하는 계획을 세우고 에이전트는 현재 상태와 목표 상태를 평가하고, 필요 시 계획을 동적으로 조정하여 목표를 달성한다.



GOAP Plugin 설명

GOAP for Unity

  • 해당 플러그인은 crashkonijn 이라는 사람이 만든 멀티스레드 기반의 유니티 잡 시스템을 사용하여 개발한 GOAP 플러그인이다.

주요 특징은 다음과 같다.

높은 퍼포먼스 - 유니티 잡 시스템을 활용한 멀티 스레드 작업으로 속도 최적화
스크립터블 오브젝트로 인젝트 가능
GOAP 를 편하게 디버깅 가능한 전용 노드 뷰어 제공


Desktop View

  • 사진에 나와있는 2D 기반의 간단한 GOAP AI 를 구성한 샘플 프로젝트에 대해 PC, 모바일 기기 Android 부하 테스트를 진행했다.

  • 플랫폼 별로 최대 2K 의 오브젝트를 움직이고, GOAP 의사결정을 진행하고 수행하는 것을 확인했다.


  • 3D 오브젝트에 대해서도 모바일(LG V5)에서 부하 테스트를 진행하였고, 대략 500-최대 1K의 오브젝트까지 컨트롤이 가능했다.

  • 예전에 개인적으로 유니티에 FlowFIeld(다익스트라) 알고리즘을 적용하여 메인 스레드 기반으로 1200 까지 늘려본적이 있는데

  • 확실히 멀티스레드 기반의 계산이 들어가서 퍼포먼스적으로 매우 우월하다고 생각한다.



Desktop View

  • 위 영상에서 구현한 NPC 의 GOAP 노드 뷰어이다.

  • 이처럼 편하게 시각적으로 편하게 디버깅이 가능하다는 장점도 존재한다. (번거롭게 로그나 인스펙터로 수치를 확인하지 않아도 됨)


GOAP 클래스 구조

Goals

  • GOAP 시스템에서는 에이전트가 달성하고자 하는 원하는 결과 또는 목표(Goals)를 설정할 수 있다.
1
2
3
4
5
6
7
8
9
using CrashKonijn.Goap.Behaviours;
 
namespace GOAP.Goals
{
    public class WanderGoal : GoalBase
    {
         
    }
}


  • GoalBase 클래스를 상속하여 사용한다.

  • 클래스 내부의 코드는 일절 필요없다. 왜냐하면 Planner 가 Goal 을 빌드할 때 필요한 Condition 들을 헤더함수를 통해 추가가 가능하기 때문이다.


1
2
3
4
5
6
7
8
9
builder.AddGoal<WanderGoal>()
    .AddCondition<IsWandering>(Comparison.GreaterThanOrEqual, 1);
 
builder.AddGoal<KillEnemy>()
    .AddCondition<EnemyHealth>(Comparison.SmallerThanOrEqual, 0);
 
builder.AddGoal<EatGoal>()
    .AddCondition<Hunger>(Comparison.SmallerThanOrEqual, 0)
    .AddCondition<Fatigue>(Comparison.SmallerThanOrEqual, 0);


  • 여기서 IsWandering, EnemyHealth, Hunger, Fatigue 등은 WorldKey 로 에이전트가 다음에 수행해야할 작업을 결정하는 지표역할을 맡는다.

  • 예를 들어, Hunger와 Fatigue 가 둘 다 0이하 여야만 다음 Goal 을 설정할 수 있다.

  • 특히 현재 목표를 완료한 뒤에는 다음 의사결정을 내리기 위한 지표들(WorldKey) 혹은 Sensor 에서 검출한 Target들을 선별하여 목표를 설정한다.



Actions

  • 에이전트가 특정 목표를 달성하기 위해 수행할 수 있는 단계들이라고 보면 된다.
  • Planner 에 Action 을 추가할 수 있다.
  • SetTarget 을 통해 목표의 위치값을 설정한다. 즉, SetTarget 으로 TargetKeyBase 클래스를 상속받는 Target을 매핑한다. 이 Target 은 Sensor 에서 검출해낸 오브젝트의 Vector3 를 뜻한다.
  • Condition 과 Effect 를 추가할 수 있다.
  • Condition 을 통해 전제 조건을 설정할 수 있다. 예시에서는 거리가 10 이하인지 판단한다.
  • Effect 는 액션이 실행될 때 WorldKey 값을 증가, 감소 하는 역할을 한다.


1
2
3
4
5
builder.AddAction<WanderAction>().SetTarget<WanderTarget>()
      .AddCondition<InRange>(Comparison.SmallerThanOrEqual, 10)
      .AddEffect<IsWandering>(EffectType.Increase)
      .SetBaseCost(5)
      .SetInRange(10);


  • 여기서 SetBaseCost, SetInRange(휴리스틱) 를 주목해야한다.

  • 코드를 보면, WanderAction 을 수행하기 위해서는 BaseCost 값과 에이전트와 WanderTarget의 Distance 를 의미하는 InRange 값의 합을 판단하여 나온 낮은 값을 기반으로 의사결정을 내린다.


  • 즉, 거리가 똑같더라도 BaseCost 가 낮으면 그 Action 을 우선적으로 수행한다는 것이다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
builder.AddAction<EatAction>()
         .SetTarget<FoodTarget>()
         .AddCondition<Hunger>(Comparison.GreaterThan, 0)
         .AddEffect<Hunger>(EffectType.Decrease)
         .SetBaseCost(8)
         .SetInRange(1);
 
     builder.AddAction<RestAction>()
         .SetTarget<HomeTarget>()
         .AddCondition<Fatigue>(Comparison.GreaterThan, 0)
         .AddEffect<Fatigue>(EffectType.Decrease)
         .SetBaseCost(10)
         .SetInRange(1);


  • WorldSensor 가 Hunger, Fatigue 둘 다 Action 수행 여부를 결정하는 값인 20(임의)을 동시에 넘겼을 경우 EatAction 을 먼저 수행하게 된다.


  • 특히 SetInRange 는 내부적으로 잡 시스템을 활용하여 A* 알고리즘의 휴리스틱과 유사하게 만들어 놓았는데

  • GraphResolverJob(Planner) 이라는 패키지 내부적으로 캐시된 클래스를 뜯어본 결과 내부적으로 다음 휴리스틱 메소드가 존재했다.

  • 단순한 직선 거리를 계산하는 코드지만, 이런 Distance 휴리스틱과 BaseCost 기반으로 어떤 Action 을 취할지에 대한 의사결정을 내릴지 계산한다.


1
2
3
4
5
6
7
8
9
10
11
12
private float Heuristic(int currentIndex, int previousIndex)
{
    var previousPosition = this.RunData.Positions[previousIndex];
    var currentPosition = this.RunData.Positions[currentIndex];
 
    if (previousPosition.Equals(InvalidPosition) || currentPosition.Equals(InvalidPosition))
    {
        return 0f;
    }
 
    return math.distance(previousPosition, currentPosition) * this.RunData.DistanceMultiplier;
}


실제 사용 방법

RestAction.cs

  • 기본적으로 Created, Start, Perform, End 메소드를 오버라이드하여 커스텀이 가능하다.

  • Perform 에서는 FSM 과 비슷하게, 전체 Goal-Actions 간의 트리 구조에서 현재 Action 을 매 프레임 업데이트한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class RestAction : ActionBase<RestAction.Data>, Iinjectable
{
 
        public override void Start(IMonoAgent agent, Data data)
        {
            data.Fatigue.enabled = false;
            data.Timer = 1f;
        }
 
        public override ActionRunState Perform(IMonoAgent agent, Data data, ActionContext context)
        {
            data.Timer -= context.DeltaTime;
            data.Fatigue.Fatigue -= context.DeltaTime * BioSigns.FatigueRestorationRate;
            data.Animator.SetBool(IS_SLEEPING, true);
 
            if (data.Target == null || data.Fatigue.Fatigue <= 0)
            {
                return ActionRunState.Stop;
            }
 
            return ActionRunState.Continue;
        }
 
        public override void End(IMonoAgent agent, Data data)
        {
            data.Animator.SetBool(IS_SLEEPING, false);
            data.Fatigue.enabled = true;
        }
}


  • 여기서 Data data 의 구조는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
public class CommonData : IActionData
{
    public ITarget Target { get; set; }
    public float Timer { get; set; }
}
 
public class Data : CommonData
{
    [GetComponent] public Animator Animator { get; set; }
 
    [GetComponent] public FatigueBehavior Fatigue { get; set; }
}


1
2
3
4
5
6
7
public class WanderAction : ActionBase<CommonData>
{
     public override void Start(IMonoAgent agent, CommonData data)
     {

     }
}
  • 제네릭 타입에 따라 파라미터 타입이 달라진다.



FatigueBehavior.cs

  • Behaviors 는 Monobehaviour 를 상속받는 클래스를 의미하며 실제로 각종 파라미터의 수치들, 유니티의 게임 오브젝트, 리스트 등 각종 모노와 관련된 작업을 수행한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[RequireComponent(typeof(Animator), typeof(AgentBehaviour))]
    public class FatigueBehavior : MonoBehaviour
    {
        [field: SerializeField] public float Fatigue { get; set; }
        [field: SerializeField] public Transform HomeTransform { get; set; }
        [FormerlySerializedAs("BioSings")] [SerializeField] private BioSignSO BioSigns;
 
        private void Awake()
        {
            Fatigue = Random.Range(0, BioSigns.MaxFatigue);
        }
 
        private void Start()
        {
            HomeTransform = Managers.BuildingManager.buildingList[0].transform;
        }
 
        private void Update()
        {
            Fatigue += Time.deltaTime * BioSigns.FatigueDepletionRate;
        }
    }





GOAP 시스템의 핵심에 대해

  • GOAP 시스템과 인게임 상황을 분리해서 생각을 해야한다.
  • GOAP 시스템의 단일 목적은 사실상 특정 Goal 에 대한 최적의 Action 을 찾는 것이다. 주의할 점은 최적의 Goal 을 찾는 행위는 인게임에 의존적이며 이는 GOAP 시스템에 포함되어 있지 않다.
  • 최적의 Goal 을 찾는 방법은 FSM 일 수도 있으며 BT 일 수도 있고, 상위에 또 다른 GOAP (Action 만을 수행하는)를 만들어 관리할 수도 있다.


GOAP 시스템은 본질적으로 다음 두 가지 일을 수행한다.

  • Goal 과 그에 연결되는 Action 의 그래프를 생성한다.
  • 해당 그래프를 기반으로 현재 설정된 Goal 을 이루기 위한 최적의 Action 을 찾는다.


여기서 그래프를 작성하기 위해 GOAP 시스템은 Action 이 어떤 종류의 Effect 를 가지고 있는지 알아야하며, 이는 실제로 인게임 데이터를 매핑(포인터)하여 시스템에서 인식하게 된다.


예를 들어, FixHungerGoal 이 있다고 가정해보면, 이 Goal 은 IsHungry <= 50 이라는 Condition 을 지니고 있다.


여기서 EatAppleAction 은 IsHungry– 감소시키는 Effect를 지니고 있다. 이렇게 EatAppleAction 을 수행하면 Goal 을 향해 게임이 어떻게 변하고 있는지 알 수 있게 되고, 그 Action 과 Goal 간의 연결이 만들어진다.


다음 그림을 살펴보면


Desktop View


  1. FixHungerGoal 이 활성화되기 위해 에이전트의 배고픔 상태(IsHungry)가 50 이하이어야 한다.
  2. IsHungrySensor는 에이전트의 현재 배고픔 상태를 평가하여, 이를 IsHungry 월드 키에 매핑한다.
  3. TransformTargetSensor는 에이전트의 목표 위치(TransformTarget)를 평가하여, 이를 TransformTarget 타겟 키에 설정한다.
  4. EatAppleAction은 에이전트가 사과를 가지고 있고(HasApple >= 1), 목표 위치에서 수행된다. 이 행동은 배고픔 상태(IsHungry)를 감소시킨다.
  5. HasAppleSensor는 에이전트가 사과를 가지고 있는지를 평가하여, 이를 HasApple 월드 키에 설정한다.
  6. ClosestAppleSensor는 가장 가까운 사과의 위치를 평가하여, 이를 ClosestApple 타겟 키에 설정한다.
  7. PickupAppleAction은 에이전트가 가장 가까운 사과 위치에서 사과를 획득하는 행동이다. 이 행동은 에이전트의 사과 상태(HasApple)를 증가시킨다.



Sensor

  • 센서는 GOAP 가 현재 게임의 상황을 이해할 수 있도록 도와주는 기능이다.

  • 크게 WorldSensor(Gloabal), TargetSensor(Local) 로 나뉜다.


Global Sensor

  • Global 센서는 모든 에이전트에 대한 정보를 제공한다. 예를 들어 IsDaytimeSensor는 모든 사람이 낮인지 밤인지 확인한다.

  • 실제로 헷갈렸던 부분인데, Sensor 가 어떻게 WorldKey 를 인식하는것인지? 였다.


1
2
builder.AddWorldSensor<HungerSensor>()
    .SetKey<Hunger>();


  • WorldKey 로 설정한 Hunger를 Planner 를 통해 WorldSensor 에 매핑하기 때문에 인식이 가능했었다. 따라서 Behavior나 Action 에서 증가, 감소를 실행하면 WorldSensor 에서 상태를 파악하고 GOAP 에 해당 정보를 전송해준다.

  • 이후 GOAP 는 해당 정보를 기반으로 어떤 Action 을 취할지 결정한다.



Local Sensor

  • Planner가 실행될 때 작동하며, 단 하나의 에이전트의 정보만 제공한다. 예를 들어 FoodTargetSensor 는 가장 가까운 음식을 찾는다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class HungerSensor : LocalWorldSensorBase
{
    ...
 
    public override SenseValue Sense(IMonoAgent agent, IComponentReference references)
    {
        return new SenseValue(Mathf.CeilToInt(references.GetCachedComponent<HungerBehavior>().Hunger));
    }
 
    ...
 
}
 
public class FoodTargetSensor : LocalTargetSensorBase, Iinjectable
{
 
    ...
 
    public override ITarget Sense(IMonoAgent agent, IComponentReference references)
    {
        Vector3 agentPosition = agent.transform.position;
        int hits = Physics.OverlapSphereNonAlloc(agentPosition, BioSigns.FoodSearchRadius, Colliders,
            BioSigns.FoodLayer);
 
        if (hits == 0)
        {
            return null;
        }
 
        for (int i = Colliders.Length - 1; i > hits; i--)
        {
            Colliders[i] = null;
        }
 
        Colliders = Colliders.OrderBy(collider =>
            collider == null
                ? 999
                : (collider.transform.position - agent.transform.position).sqrMagnitude).ToArray();
 
        return new PositionTarget(Colliders[0].transform.position);
    }
 
 
    ...
 
}


Injector

  • Data Injection 은 다른 객체나 모듈에 런타임 데이터를 제공하는 디자인 패턴이다.
  • GOAP 시스템에서 관리하는 핵심 클래스(Goal, Action, Sensor)에 특정 데이터나 종속성을 제공하는 데 사용된다.
  • 특히 디커플링을 위해 자주 사용하는데, 핵심 로직을 수정하지 않고 스크립터블 오브젝트로 Injectable 데이터를 주입해서 GOAP 클래스를 일반적이고 재사용 가능하게 활용할 수 있다.
  • 예를 들어, 직업별로 이동속도와 같은 스탯이 다를 경우 직업별로 스크립터블 오브젝트를 생성하고 Injectable 인터페이스를 각 클래스에 상속시켜서 모듈식으로 활용이 가능하다.



연구 목표

  • GOAP 를 제대로 활용하기 위해서는, 인게임 상태와 GOAP 시스템을 분리해서 생각해야한다는 점


  • 이를 위해 FSM, BT, Layered GOAP 들 중 프로젝트에 알맞는 방법을 활용하여 복잡한 알고리즘 속에서 최적의 Goal 을 선택하는 로직을 잘 구현해야한다는 점


  • 특정 Goal 을 달성하기 위한 최적의 Action 을 수행하기 위해 Condition 과 Effect 를 설정하여 알고리즘을 구현해야한다는 점


  • 각 GOAP 들의 Goal, Action 들에 대한 데이터 관리 방법을 정해야 한다는 점 (세이브/로드도 고려해야함)
  • 이 부분은 crashkonjin 이 새로이 출시한 Blackboard 시스템을 사용하면 될 것 같다.