Unity Flocking 알고리즘으로 미니언 몬스터 이동 경로를 자연스럽게 만드는 법

Unity C#에서 Separation·Alignment·Cohesion 기반 Flocking 알고리즘을 미니언 몬스터 이동에 적용한다. 뭉침, 겹침, 목표 추적 문제를 가중치·이웃 검색·속도 제한으로 해결하는 실전 구현 가이드다.

✓ 복사 완료!
챕터일반 가이드
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity Flocking 알고리즘으로 미니언 몬스터 이동 경로를 자연스럽게 만드는 법

핵심 요약

Flocking은 주변 개체와의 거리·방향·중심점을 이용해 군집 이동을 만드는 알고리즘이다. Unity에서는 Separation, Alignment, Cohesion 조향 벡터에 목표 추적 벡터를 합산하고 이웃 검색 반경과 힘 가중치를 제한하면 미니언이 서로 겹치지 않으면서 목표를 향해 이동한다.

미니언 몬스터 이동에 Flocking 알고리즘이 필요한 이유

여러 미니언이 같은 플레이어 또는 웨이포인트를 향해 MoveTowards만 수행하면 동일한 목적지에 수렴한다. 결과적으로 모델이 겹치고 앞선 미니언이 뒤쪽 개체의 경로를 막으며 이동 모습도 기계적으로 보인다.

Flocking은 각 미니언이 가까운 이웃만 관찰해 다음 세 가지 힘을 계산하도록 한다.

힘목적미니언 이동에서의 효과
Separation너무 가까운 이웃에게서 멀어짐충돌과 겹침 감소
Alignment주변 이웃의 평균 진행 방향에 맞춤같은 무리가 같은 흐름으로 이동
Cohesion주변 이웃의 중심으로 접근무리가 흩어지는 현상 완화
Target Seek전투 목표나 웨이포인트로 이동군집이 실제 게임 목적을 수행

각 조향 힘을 합산한 뒤 최대 가속도와 최대 속도를 제한한다.

F=wsFseparation+waFalignment+wcFcohesion+wtFtargetF = w_sF_{separation} + w_aF_{alignment} + w_cF_{cohesion} + w_tF_{target}

여기서 w는 각 행동의 우선순위를 조절하는 가중치다. 근접 회피가 목표 추적보다 강해야 서로 밀집한 미니언이 자연스럽게 퍼질 수 있다.

Separation, Alignment, Cohesion과 목표 추적 벡터가 적용된 미니언 군집 이동 예시

Flocking에서 미니언이 한곳에 뭉치는 문제는 왜 발생하는가?

가장 흔한 원인은 모든 개체가 동일한 목표점에 대해 강한 Target Seek 힘을 받는 것이다. Cohesion까지 과도하면 군집 중심과 목표점 모두로 수렴해 원형 덩어리가 된다.

증상주된 원인조정 방법
목표 앞에서 겹침Target Seek가 너무 강함목표 힘을 낮추고 도착 반경 적용
군집이 한 덩어리가 됨Cohesion이 과도함Cohesion 가중치 또는 이웃 반경 축소
서로 튕기며 떨림Separation이 너무 강하거나 프레임마다 방향이 급변최대 가속도 제한, 가중치 완화
군집이 흩어짐이웃을 찾지 못하거나 Alignment/Cohesion이 약함검색 반경 확대 또는 Cohesion 소폭 증가

전투 게임에서는 목표점 자체를 분산하는 방법도 유효하다. 예를 들어 플레이어 중심을 직접 목표로 삼는 대신, 미니언별 슬롯 위치나 목표 주변의 랜덤 오프셋을 Target Seek에 사용한다. Flocking은 국소 회피를 담당하고 공격 위치 배정은 별도 로직으로 유지하는 편이 역할이 명확하다.

Unity C#에서 Flocking 미니언 이동을 어떻게 구현할까?

다음 구현은 3D 월드에서 XZ 평면만 이동하는 CharacterController 기반 예시다. 리지드바디 물리를 쓰는 게임이라면 마지막 이동 부분만 Rigidbody.MovePosition 또는 속도 설정으로 바꾸면 된다.

1. 이웃 개체를 찾고 조향 벡터를 계산한다

Physics.OverlapSphereNonAlloc을 사용하면 매 프레임 배열 할당 없이 가까운 미니언을 찾을 수 있다. 군집 수가 적다면 단순한 리스트 순회도 충분하지만 수십~수백 개체에서는 이웃 검색 비용을 먼저 확인해야 한다.

using UnityEngine;

[RequireComponent(typeof(CharacterController))]
public sealed class FlockingMinion : MonoBehaviour
{
    [Header("References")]
    [SerializeField] private Transform target;
    [SerializeField] private LayerMask minionMask;

    [Header("Movement")]
    [SerializeField] private float maxSpeed = 3.5f;
    [SerializeField] private float maxForce = 8f;
    [SerializeField] private float neighborRadius = 3f;
    [SerializeField] private float separationRadius = 1.1f;

    [Header("Weights")]
    [SerializeField] private float separationWeight = 2.2f;
    [SerializeField] private float alignmentWeight = 0.8f;
    [SerializeField] private float cohesionWeight = 0.6f;
    [SerializeField] private float targetWeight = 1.4f;

    private readonly Collider[] neighborBuffer = new Collider[32];
    private CharacterController controller;
    private Vector3 velocity;

    private void Awake()
    {
        controller = GetComponent<CharacterController>();
    }

    private void Update()
    {
        Vector3 force = CalculateFlockingForce();
        velocity = Vector3.ClampMagnitude(velocity + force * Time.deltaTime, maxSpeed);
        controller.Move(velocity * Time.deltaTime);

        if (velocity.sqrMagnitude > 0.01f)
            transform.forward = Vector3.Slerp(transform.forward, velocity.normalized, 12f * Time.deltaTime);
    }

    private Vector3 CalculateFlockingForce()
    {
        Vector3 separation = Vector3.zero;
        Vector3 alignment = Vector3.zero;
        Vector3 center = Vector3.zero;
        Vector3 cohesion = Vector3.zero;
        int count = 0;

        int hitCount = Physics.OverlapSphereNonAlloc(
            transform.position, neighborRadius, neighborBuffer, minionMask);

        for (int i = 0; i < hitCount; i++)
        {
            FlockingMinion neighbor = neighborBuffer[i].GetComponent<FlockingMinion>();
            if (neighbor == null || neighbor == this)
                continue;

            Vector3 offset = transform.position - neighbor.transform.position;
            offset.y = 0f;
            float distance = offset.magnitude;

            if (distance > 0.001f && distance < separationRadius)
                separation += offset.normalized / distance;

            alignment += neighbor.velocity;
            center += neighbor.transform.position;
            count++;
        }

        if (count > 0)
        {
            alignment = SteerTowards(alignment / count);
            center /= count;
            cohesion = SteerTowards(center - transform.position);
        }

        separation = SteerTowards(separation);
        Vector3 seekTarget = target == null
            ? Vector3.zero
            : SteerTowards(target.position - transform.position);

        return separation * separationWeight
             + alignment * alignmentWeight
             + cohesion * cohesionWeight
             + seekTarget * targetWeight;
    }

    private Vector3 SteerTowards(Vector3 desiredDirection)
    {
        desiredDirection.y = 0f;
        if (desiredDirection.sqrMagnitude < 0.0001f)
            return Vector3.zero;

        Vector3 desiredVelocity = desiredDirection.normalized * maxSpeed;
        return Vector3.ClampMagnitude(desiredVelocity - velocity, maxForce);
    }
}

2. 가중치와 반경을 게임 상황에 맞게 조절한다

초기값은 다음 관계를 우선으로 잡으면 튜닝이 쉬워진다.

  1. separationRadius를 캐릭터 지름보다 약간 크게 설정한다.
  2. separationWeight를 가장 높게 두어 겹침을 먼저 막는다.
  3. targetWeight를 그다음으로 두고 군집이 목표까지 도달하는지 확인한다.
  4. 마지막으로 alignmentWeight, cohesionWeight를 낮은 값부터 올려 이동 흐름과 결속감을 조절한다.

neighborRadius가 너무 크면 화면 밖의 개체까지 같은 집단처럼 반응한다. 반대로 너무 작으면 이웃 수가 자주 0이 되어 군집이 끊어진다. 탑다운 액션 게임의 보통 크기 미니언이라면 시작점으로 separationRadius = 1.0~1.5, neighborRadius = 2.5~4.0 정도를 두고 실제 충돌 반경에 맞춰 조정할 수 있다.

flowchart LR
    A[주변 미니언 검색] --> B[Separation 계산]
    A --> C[Alignment 계산]
    A --> D[Cohesion 계산]
    B --> E[목표 추적 벡터와 합산]
    C --> E
    D --> E
    E --> F[최대 힘과 속도 제한]
    F --> G[CharacterController 이동]

3. 경로 탐색 결과와 Flocking을 분리한다

NavMesh 또는 A*는 목적지까지의 큰 경로를 찾는 데 적합하고 Flocking은 가까운 미니언과의 국소적인 움직임을 다루는 데 적합하다. 둘을 같은 알고리즘으로 해결하려 하면 장애물 회피와 군집 움직임 모두 불안정해지기 쉽다.

실무에서는 다음처럼 분리한다.

  • NavMeshAgent 또는 경로 탐색 시스템이 다음 웨이포인트를 제공한다.
  • 웨이포인트 방향을 Target Seek의 목표 방향으로 사용한다.
  • Flocking은 그 방향 주변에서 미니언 간 간격과 흐름만 보정한다.
  • 전투 사거리 안에서는 목표 추적을 낮추고 공격 슬롯 또는 정지 규칙으로 전환한다.

Unity NavMeshAgent를 이미 사용한다면 agent.updatePosition = false로 위치 적용을 직접 제어하거나 agent.desiredVelocity를 목표 방향으로 사용해 Flocking 힘과 결합할 수 있다. 단, NavMeshAgent와 CharacterController가 동시에 위치를 갱신하지 않도록 이동 책임은 하나만 선택해야 한다.

Flocking 성능은 어떻게 최적화할까?

모든 미니언이 모든 미니언을 검사하면 계산량은 다음과 같다.

O(N2)O(N^2)

미니언 수가 30명 정도라면 단순 구현도 대개 충분하다. 하지만 100명 이상이 동시에 이동하는 상황에서는 이웃 검색을 제한해야 한다.

규모우선 적용할 방법이유
10~30마리단순 리스트 순회구현과 디버깅이 가장 단순함
30~100마리OverlapSphereNonAlloc과 레이어 분리GC 할당과 불필요한 충돌체 검색 감소
100마리 이상Spatial Hash 또는 Uniform Grid근처 셀만 검사해 이웃 탐색 비용 감소
대규모 군중Job System + Burst 또는 ECS병렬 처리와 데이터 지향 구조 활용

먼저 프로파일러에서 Physics.OverlapSphereNonAlloc, GetComponent, 이동 갱신 비용을 확인해야 한다. 성능 문제가 확인되기 전부터 ECS나 복잡한 공간 자료구조를 도입할 필요는 없다. 특히 위 예시의 GetComponent<FlockingMinion>() 호출이 프로파일링에서 비용으로 나타난 경우에만 콜라이더와 미니언 컴포넌트의 캐시 구조를 추가하는 것이 좋다.

자주 묻는 질문 (FAQ)

Flocking만으로 벽과 장애물을 피할 수 있는가?

아니다. Flocking의 Separation은 주로 다른 미니언과의 간격 유지용이다. 벽과 복잡한 지형 경로는 NavMesh, A* 또는 별도의 장애물 회피 조향으로 처리한다.

미니언이 목표 주변에서 계속 빙빙 도는 이유는 무엇인가?

목표 추적, Separation, 속도 관성이 서로 상쇄되기 때문이다. 공격 사거리 안에서는 목표 추적을 줄이고 속도를 감속하거나 미니언별 공격 슬롯을 목표로 지정한다.

2D 게임에서도 같은 공식을 사용할 수 있는가?

가능하다. Vector3의 XZ 평면 대신 Vector2의 XY 평면을 사용하면 된다. 이웃 검색은 Physics2D.OverlapCircleNonAlloc으로 바꾼다.

정리

미니언 군집 이동은 목표를 향하는 힘 하나로 해결하기보다 목표 추적과 Separation·Alignment·Cohesion을 분리해 합산해야 안정적이다. 작은 규모에서는 단순한 C# 구현과 반경·가중치 튜닝으로 시작하고 프로파일링 결과가 필요할 때만 공간 분할이나 Job System으로 확장하는 것이 가장 안전하다.