플로킹 알고리즘으로 조류와 군중 AI 만들기

분리·정렬·응집 규칙을 조합해 자연스럽게 움직이는 집단 AI를 구현한다. Unity C# 예제로 이웃 탐색, 가중치 조절, 장애물 회피와 성능 최적화까지 살펴본다.

✓ 복사 완료!
챕터일반 가이드
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
플로킹 알고리즘으로 조류와 군중 AI 만들기

플로킹이란 무엇인가

플로킹은 개체마다 복잡한 경로를 미리 지정하지 않아도 가까운 이웃을 기준으로 몇 가지 단순한 규칙을 적용해 무리처럼 보이는 움직임을 만드는 기법이다. 새 떼, 물고기 떼, 좀비 군중, 드론 편대처럼 집단의 형태와 흐름이 중요한 게임 오브젝트에 잘 어울린다.

대표적인 플로킹 모델은 Craig Reynolds가 정리한 세 가지 행동을 기반으로 한다.

  • 분리(Separation): 너무 가까운 이웃에게서 멀어진다.
  • 정렬(Alignment): 주변 개체의 이동 방향과 속도에 맞춘다.
  • 응집(Cohesion): 주변 개체의 중심 쪽으로 이동한다.

세 규칙은 각각 단순하지만 가중치를 다르게 주고 모든 개체가 매 프레임 반복하면 살아 있는 무리처럼 보이는 결과가 나온다.

flowchart TD
    A[현재 개체 위치와 속도] --> B[탐색 반경 안의 이웃 수집]
    B --> C[분리 벡터 계산]
    B --> D[정렬 벡터 계산]
    B --> E[응집 벡터 계산]
    C --> F[가중 합으로 조향력 생성]
    D --> F
    E --> F
    F --> G[속도와 위치 갱신]

세 가지 힘을 벡터로 계산하기

현재 개체의 위치를 pp, 속도를 vv, 이웃 집합을 NN이라고 하자. 이웃의 수는 ∣N∣|N|이다.

분리

분리는 가까운 이웃일수록 더 강하게 밀어내는 힘이다. 각 이웃과의 거리 제곱으로 나누면 거의 겹친 개체를 빠르게 떼어 낼 수 있다.

S=∑j∈Np−pjmax⁡(∥p−pj∥2,ϵ)S = \sum_{j \in N}\frac{p - p_j}{\max(\lVert p-p_j \rVert^2, \epsilon)}

ϵ\epsilon은 거리가 0에 가까울 때 나눗셈이 불안정해지는 것을 막는 작은 값이다. 실제 구현에서는 분리 반경을 전체 이웃 탐색 반경보다 작게 두는 편이 자연스럽다.

정렬

정렬은 이웃 속도의 평균과 현재 속도 사이의 차이를 조향력으로 사용한다.

A=1∣N∣∑j∈Nvj−vA = \frac{1}{|N|}\sum_{j \in N}v_j - v

이 힘이 너무 강하면 모든 개체가 한 덩어리로 굳어 보인다. 방향이 어느 정도 맞아 보일 정도로만 적용하는 것이 좋다.

응집

응집은 이웃 위치의 평균인 무리 중심으로 향하는 힘이다.

C=(1∣N∣∑j∈Npj)−pC = \left(\frac{1}{|N|}\sum_{j \in N}p_j\right) - p

응집만 사용하면 개체가 한 점에 모이려 한다. 따라서 분리와 반드시 함께 사용해야 한다.

세 힘은 보통 다음처럼 가중 합한다.

F=wsS+waA+wcCF = w_sS + w_aA + w_cC

분리·정렬·응집 힘이 개체의 최종 이동 방향에 합쳐지는 모습

Unity C# 구현 예제

아래 예제는 같은 FlockAgent를 가진 오브젝트를 찾아 기본 플로킹을 적용한다. 학습과 소규모 프로토타입을 위한 구현이므로 개체 수가 많아지면 뒤에서 설명하는 공간 분할을 적용해야 한다.

using System.Collections.Generic;
using UnityEngine;

public class FlockAgent : MonoBehaviour
{
    [Header("Movement")]
    [SerializeField] private float maxSpeed = 5f;
    [SerializeField] private float maxForce = 8f;

    [Header("Neighbour Search")]
    [SerializeField] private float neighbourRadius = 4f;
    [SerializeField] private float separationRadius = 1.2f;
    [SerializeField] private LayerMask agentMask;

    [Header("Weights")]
    [SerializeField] private float separationWeight = 1.8f;
    [SerializeField] private float alignmentWeight = 1.0f;
    [SerializeField] private float cohesionWeight = 0.9f;

    private Vector3 velocity;

    private void Start()
    {
        velocity = transform.forward * maxSpeed * 0.5f;
    }

    private void Update()
    {
        Collider[] hits = Physics.OverlapSphere(
            transform.position,
            neighbourRadius,
            agentMask);

        Vector3 separation = Vector3.zero;
        Vector3 averageVelocity = Vector3.zero;
        Vector3 averagePosition = Vector3.zero;
        int neighbourCount = 0;

        foreach (Collider hit in hits)
        {
            FlockAgent other = hit.GetComponent<FlockAgent>();
            if (other == null || other == this)
            {
                continue;
            }

            Vector3 offset = transform.position - other.transform.position;
            float distance = offset.magnitude;

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

            averageVelocity += other.velocity;
            averagePosition += other.transform.position;
            neighbourCount++;
        }

        Vector3 steering = Vector3.zero;

        if (neighbourCount > 0)
        {
            Vector3 alignment = averageVelocity / neighbourCount - velocity;
            Vector3 cohesion = averagePosition / neighbourCount - transform.position;

            steering += LimitForce(separation) * separationWeight;
            steering += LimitForce(alignment) * alignmentWeight;
            steering += LimitForce(cohesion) * cohesionWeight;
        }

        velocity += LimitForce(steering) * Time.deltaTime;
        velocity = Vector3.ClampMagnitude(velocity, maxSpeed);
        transform.position += velocity * Time.deltaTime;

        if (velocity.sqrMagnitude > 0.0001f)
        {
            transform.forward = velocity.normalized;
        }
    }

    private Vector3 LimitForce(Vector3 force)
    {
        return Vector3.ClampMagnitude(force, maxForce);
    }
}

각 에이전트에는 Collider를 붙이고 전용 레이어를 할당한 뒤 agentMask에 그 레이어를 지정한다. Collider는 충돌 물리용이 아니라 이웃 탐색용이므로 필요하다면 isTrigger를 켜고 Rigidbody 설정을 분리하는 편이 관리하기 쉽다.

움직임이 부자연스러울 때 조절할 값

가중치는 정답이 하나인 값이 아니다. 오브젝트 크기, 목표 속도, 맵 크기에 따라 달라진다. 다만 증상을 기준으로 조절하면 시행착오를 줄일 수 있다.

증상우선 조절할 값이유
개체가 서로 겹친다separationWeight, separationRadius가까운 개체를 밀어내는 힘이 부족하다.
무리가 흩어진다cohesionWeight, neighbourRadius이웃 중심으로 돌아오는 힘이나 인식 범위가 작다.
모두 같은 방향으로 굳는다alignmentWeight 감소정렬이 다른 행동을 압도한다.
갑자기 꺾이거나 떨린다maxForce 감소한 프레임에 바뀌는 속도량이 너무 크다.
벽 근처에서 뭉친다회피 힘 추가세 기본 규칙만으로는 장애물을 인식하지 못한다.

속도와 조향력에 상한을 두는 것은 중요하다. 상한이 없으면 응집이나 분리 벡터가 커진 순간 개체가 튀어 나가거나 회전이 지나치게 급해진다.

목표 추적과 장애물 회피 추가하기

새 떼가 특정 방향으로 이동하게 하려면 목표 지점으로 향하는 조향력을 추가하면 된다.

[SerializeField] private Transform target;
[SerializeField] private float targetWeight = 0.6f;

private Vector3 Seek(Vector3 targetPosition)
{
    Vector3 desiredVelocity = (targetPosition - transform.position).normalized * maxSpeed;
    return desiredVelocity - velocity;
}

Update에서 최종 조향력에 다음 코드를 더한다.

if (target != null)
{
    steering += LimitForce(Seek(target.position)) * targetWeight;
}

장애물 회피는 진행 방향으로 SphereCast 또는 Raycast를 쏜 뒤 충돌 표면의 법선 방향으로 조향력을 더하는 방식이 단순하고 효과적이다. 다만 감지 거리가 짧으면 장애물 바로 앞에서 급회전하고 너무 길면 불필요하게 크게 우회한다. 이동 속도에 비례해 감지 거리를 조절하는 방식이 안정적이다.

private Vector3 AvoidObstacle()
{
    float lookAhead = Mathf.Lerp(1.5f, 5f, velocity.magnitude / maxSpeed);

    if (Physics.SphereCast(
        transform.position,
        0.35f,
        velocity.normalized,
        out RaycastHit hit,
        lookAhead))
    {
        return hit.normal * maxForce;
    }

    return Vector3.zero;
}

성능: 모든 개체를 전부 비교하지 않기

단순 구현은 각 개체가 다른 모든 개체를 검사한다. 개체 수를 nn이라고 하면 한 프레임의 비교 횟수는 대략 다음과 같다.

O(n2)O(n^2)

수십 개체에서는 큰 문제가 없지만 수백 또는 수천 개체에서는 병목이 되기 쉽다. 이 경우에는 월드 공간을 일정한 크기의 셀로 나누는 공간 해시나 균일 그리드를 사용한다. 개체는 자기 셀과 인접 셀만 검사하면 되므로 실제 이웃 탐색량을 크게 줄일 수 있다.

최적화할 때는 다음 순서가 실용적이다.

  1. Profiler로 실제 병목이 이웃 탐색인지 먼저 확인한다.
  2. GetComponent 호출을 반복하지 않도록 에이전트 참조를 캐시한다.
  3. Physics.OverlapSphereNonAlloc으로 반복 할당을 줄인다.
  4. 개체 수가 많으면 공간 해시나 그리드 기반 이웃 탐색으로 바꾼다.
  5. 매우 큰 군중은 화면 거리별 갱신 주기와 표현 수준을 낮춘다.

마무리

플로킹의 핵심은 정교한 규칙 하나가 아니라 단순한 조향 행동을 적절히 제한하고 조합하는 데 있다. 먼저 분리·정렬·응집만으로 안정적인 움직임을 만든 뒤 목표 추적, 장애물 회피, 리더 개체, 상태 전환을 차례로 더해 보자.

특히 가중치는 코드에 고정하기보다 Inspector에서 조절할 수 있게 두는 편이 좋다. 같은 알고리즘이라도 가중치와 반경만 바꿔 새 떼의 느슨한 움직임, 물고기 떼의 밀집도, 긴장한 군중의 회피 행동을 서로 다르게 표현할 수 있다.