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

핵심 요약
Flocking은 주변 개체와의 거리·방향·중심점을 이용해 군집 이동을 만드는 알고리즘이다. Unity에서는 Separation, Alignment, Cohesion 조향 벡터에 목표 추적 벡터를 합산하고 이웃 검색 반경과 힘 가중치를 제한하면 미니언이 서로 겹치지 않으면서 목표를 향해 이동한다.
미니언 몬스터 이동에 Flocking 알고리즘이 필요한 이유
여러 미니언이 같은 플레이어 또는 웨이포인트를 향해 MoveTowards만 수행하면 동일한 목적지에 수렴한다. 결과적으로 모델이 겹치고 앞선 미니언이 뒤쪽 개체의 경로를 막으며 이동 모습도 기계적으로 보인다.
Flocking은 각 미니언이 가까운 이웃만 관찰해 다음 세 가지 힘을 계산하도록 한다.
| 힘 | 목적 | 미니언 이동에서의 효과 |
|---|---|---|
| Separation | 너무 가까운 이웃에게서 멀어짐 | 충돌과 겹침 감소 |
| Alignment | 주변 이웃의 평균 진행 방향에 맞춤 | 같은 무리가 같은 흐름으로 이동 |
| Cohesion | 주변 이웃의 중심으로 접근 | 무리가 흩어지는 현상 완화 |
| Target Seek | 전투 목표나 웨이포인트로 이동 | 군집이 실제 게임 목적을 수행 |
각 조향 힘을 합산한 뒤 최대 가속도와 최대 속도를 제한한다.
여기서 w는 각 행동의 우선순위를 조절하는 가중치다. 근접 회피가 목표 추적보다 강해야 서로 밀집한 미니언이 자연스럽게 퍼질 수 있다.

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. 가중치와 반경을 게임 상황에 맞게 조절한다
초기값은 다음 관계를 우선으로 잡으면 튜닝이 쉬워진다.
separationRadius를 캐릭터 지름보다 약간 크게 설정한다.separationWeight를 가장 높게 두어 겹침을 먼저 막는다.targetWeight를 그다음으로 두고 군집이 목표까지 도달하는지 확인한다.- 마지막으로
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 성능은 어떻게 최적화할까?
모든 미니언이 모든 미니언을 검사하면 계산량은 다음과 같다.
미니언 수가 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으로 확장하는 것이 가장 안전하다.



