[25.04.03] TIL( 길찾기 )

설민우·2025년 4월 3일

내일배움캠프 - Unity

목록 보기
14/85

오늘 공부한 내용은 AI의 길찾기에 관련된 내용이었습니다. 지금 프로젝트에서 필요한 길찾기의 기능은 아래와 같았습니다.

기본 골자 : 뱀파이어 서바이벌

  • 이동 : 오직 플레이어를 향해서만 이동합니다
  • 같은 동료 회피 : 적 오브젝트라면 같은 적 오브젝트와는 겹쳐지지 않게 이동해야 합니다
  • 둘러싸기 : 플레이어 앞에서도 겹쳐지면 안되고 둘러싸는 형태로 이동해야 합니다

조사한 결과 이에 대한 해결책은 크게 3가지 였습니다.

( 1. Steering Behaviors )

유체 시뮬레이션 기반 이동입니다.

  • Seek & Arrive : 적들이 플레이어를 향해 이동하지만, 목표지점에 가까워 질수록 속도를 줄여 부드럽게 멈추게 함
  • Separation : 근처에 다른 적이 있으면 서로 밀어내도록 설정해 겹치지 않게 유ㅜ지
  • alignment & cohesion 주변 적들과 자연스럽게 정렬되거나 뭉치도록 조정

-> 구현은 NavMeshAgent 대신 벡터 기반의 이동 로직을 구현해야 합니다.

( 2. 보이드 알고리즘 활용 )

새 떼나 물고기 때처럼 움직이게 하는 알고리즘으로 적이 자연스럽게 모이면서도 겹치지 않게 합니다

  • 분리 : 너무 가까이 있는 적과는 거리를 둔다
  • 응집 : 근처의 적들과 함께 이동함
  • 정렬 : 주변의 적들과 유사한 방향으로 이동

( 3. NavMeshAgent + Local Avoidance )

  • 네브메쉬를 사용하면 적들이 서로 겹치지 않게 할 수 있습니다.
  • 가장 구현하기 쉬우나 연산량이 많아지면 퍼포먼스가 떨어질 수 있습니다.
  • 소수의 적이라면 네브 메쉬를 이용하는 것이 좋다
  • 대량의 적이면 Steering Behaviors + Boid 알고리즘이 좋다

원래는 뱀파이어 서바이벌의 3d 환경인 특성상 1과 2를 합친 알고리즘을 사용하려 했습니다.

using UnityEngine;
using System.Collections.Generic;

public class BoidEnemy : MonoBehaviour
{
    public Transform target; // 플레이어 타겟
    public float moveSpeed = 3f; // 이동 속도
    public float neighborRadius = 2f; // 주변 적 탐색 범위
    public float separationDistance = 1f; // 최소 거리
    public float separationForce = 2f; // 분리 강도

    private Vector3 velocity; // 현재 속도

    void Update()
    {
        if (target == null) return;

        Vector3 targetDirection = (target.position - transform.position).normalized;
        Vector3 separationVector = CalculateSeparation();

        // 타겟을 향한 이동 + Separation 적용
        velocity = (targetDirection + separationVector).normalized * moveSpeed;
        transform.position += velocity * Time.deltaTime;

        // 회전 방향 조정 (플레이어를 바라보도록)
        if (velocity.sqrMagnitude > 0.01f)
        {
            transform.forward = velocity;
        }
    }

    private Vector3 CalculateSeparation()
    {
        Vector3 separationVector = Vector3.zero;
        int count = 0;

        Collider[] colliders = Physics.OverlapSphere(transform.position, neighborRadius);
        foreach (Collider col in colliders)
        {
            if (col.gameObject == gameObject) continue;

            BoidEnemy neighbor = col.GetComponent<BoidEnemy>();
            if (neighbor != null)
            {
                float distance = Vector3.Distance(transform.position, neighbor.transform.position);
                if (distance < separationDistance)
                {
                    separationVector += (transform.position - neighbor.transform.position).normalized / distance;
                    count++;
                }
            }
        }

        if (count > 0)
        {
            separationVector /= count;
            separationVector *= separationForce;
        }

        return separationVector;
    }
}

외부 자료등을 통해 알아본 스크립트는 위와 같았는데, 이를 기반으로 시도해본 결과 서로 적 오브젝트가 비벼지거나, 플레이어 근처에서 멈추지 않고 계속해서 이동하는 문제가 있었습니다

이를 해결하기 위해 내 앞의 적이 멈췄다면 그 뒤의 적도 서로 전달을 받는 형식으로 멈추도록 변경해 보았습니다

하지만 이렇게 변경하니 플레이어 근처의 최적해 자리로 이동하지 못하는 문제가 있었습니다.

때문에 먼저 간단하게 해결 할 수 있는 네브 메쉬를 사용해 보는 방향으로 방법을 틀었습니다


using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;

public class BoidEnemyNavMesh : MonoBehaviour
{
    public enum EnemyState { Move, Idle }
    public EnemyState state = EnemyState.Move;

    public Transform target;
    public float stopDistance = 2f;
    public float baseRadius = 2f;
    public float radiusSpacing = 1.5f;
    public float positionThreshold = 0.5f;
    public float movementThreshold = 1.5f;
    public float checkInterval = 0.5f;
    public float recalculateInterval = 2f; // 포메이션 재계산 주기

    private NavMeshAgent agent;
    private static List<BoidEnemyNavMesh> allEnemies = new List<BoidEnemyNavMesh>();
    private Vector3 targetFormationPos;
    private bool formationPositionSet = false;
    private static Vector3 lastPlayerPosition;
    private float nextCheckTime = 0f;
    private float nextRecalculateTime = 0f;

    void OnEnable()
    {
        allEnemies.Add(this);
    }

    void OnDisable()
    {
        allEnemies.Remove(this);
        NotifyFormationChange(); // 적이 사라지면 포메이션 재정렬
    }
    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        agent.stoppingDistance = 0;
        agent.updateRotation = false;
        agent.autoBraking = true;

        agent.speed = 0.5f;          // 기본 이동 속도 (원하는 속도로 조정 가능)
        agent.acceleration = 15f;    // 가속도 (값이 너무 낮으면 탄성처럼 밀리는 느낌이 생김)
       // agent.angularSpeed = 120f;   // 회전 속도 (너무 크면 급격히 방향 전환, 너무 작으면 둔함)

        agent.avoidancePriority = Random.Range(30, 60);

        agent.obstacleAvoidanceType = ObstacleAvoidanceType.HighQualityObstacleAvoidance;

        agent.radius = 0.3f;
    }
    void Update()
    {
        if (target == null) return;

        float currentTime = Time.time;
        if (currentTime >= nextCheckTime)
        {
            nextCheckTime = currentTime + checkInterval;
            CheckPlayerMovement();
        }

        if (currentTime >= nextRecalculateTime)
        {
            nextRecalculateTime = currentTime + recalculateInterval;
            NotifyFormationChange();
        }

        switch (state)
        {
            case EnemyState.Move:
                HandleMoveState();
                break;
            case EnemyState.Idle:
                HandleIdleState();
                break;
        }
    }

    private void HandleMoveState()
    {
        if (!formationPositionSet)
        {
            targetFormationPos = GetFormationPosition();
            agent.SetDestination(targetFormationPos);
            formationPositionSet = true;
        }

        if (!agent.pathPending && agent.remainingDistance <= positionThreshold && agent.velocity.magnitude < 0.1f)
        {
            ChangeState(EnemyState.Idle);
        }
    }

    private void HandleIdleState()
    {
        agent.isStopped = true;
        agent.velocity = Vector3.zero;
    }

    private void ChangeState(EnemyState newState)
    {
        state = newState;

        switch (newState)
        {
            case EnemyState.Move:
                agent.isStopped = false;
                formationPositionSet = false;
                break;
            case EnemyState.Idle:
                agent.isStopped = true;
                agent.velocity = Vector3.zero;
                break;
        }
    }

    private void CheckPlayerMovement()
    {
        float distanceMoved = Vector3.Distance(lastPlayerPosition, target.position);
        if (distanceMoved >= movementThreshold)
        {
            lastPlayerPosition = target.position;
            NotifyFormationChange();
        }
    }

    private void NotifyFormationChange()
    {
        foreach (var enemy in allEnemies)
        {
            enemy.ChangeState(EnemyState.Move);
        }
    }

    private Vector3 GetFormationPosition()
    {
        int index = allEnemies.IndexOf(this);
        if (index == -1) return transform.position;

        int ringIndex = 0;
        int ringCapacity = 6;
        int ringStart = 0;

        while (index >= ringStart + ringCapacity)
        {
            ringStart += ringCapacity;
            ringCapacity += 6;
            ringIndex++;
        }

        float radius = baseRadius + ringIndex * radiusSpacing;
        float angleStep = 360f / ringCapacity;
        float angle = (index - ringStart) * angleStep;

        Vector3 formationPos = target.position + new Vector3(
            Mathf.Cos(angle * Mathf.Deg2Rad),
            0,
            Mathf.Sin(angle * Mathf.Deg2Rad)
        ) * radius;

        NavMeshHit hit;
        if (NavMesh.SamplePosition(formationPos, out hit, 1.0f, NavMesh.AllAreas))
        {
            return hit.position;
        }
        return formationPos;
    }
}

원래는 네브 메쉬를 사용한 상태에서도 플레이어를 감싸지 못하거나, 서로 비벼지면서 떨리는 문제가 있었는데 이를 Ring 형태의 포메이션을 구성하도록 하는것으로 문제를 해결해 보았습니다.

또한, 플레이어가 일정 범위를 이동하게 되면 재 이동.
적 오브젝트가 죽어 링 공간이 비게 되면 재 배치.

기능을 추가해서 보완했습니다.


( 결론 )

우선은 네브 메쉬를 기반으로 구현해 보았습니다. 아무래도 rb를 이용해서 위의 기능을 구현하는 것은 쉽지 않을 것으로 보입니다만 계속해서 도전해볼 생각입니다.
특히 ring 형태의 구성을 가져가도록 해보면 변화점이 있을 것이라 생각합니다.

profile
클라이언트 개발자를 지망하고 있습니다.

0개의 댓글