■ 겹침 해소
겹침 해소는 IRepulsionReceiver를 구현한 대상만 순회한다. 장애물은 이 인터페이스를 구현하지 않으므로 애초에 이 명부에 들어 있지 않다. 밀어내기는 해도 밀려나지는 않기 때문이다.
public class EnemyRegister : MonoBehaviour
{
private void Update()
{
foreach (var self in _repulsionsReceivers)
{
var selfPos = self.Position;
_enemyHash.Query(selfPos, _enemyBuffer);
_obstacleHash.Query(selfPos, _obstacleBuffer);
// 이번 프레임에 내가 물러나야 할 변위
var correction = Vector3.zero;
// 몇 마리와 겹쳤나 (나중에 평균 낼 때 사용)
var overlapCount = 0;
// 장애물이 미리 틀어주는 방향
var obsForce = Vector3.zero;
foreach (var other in _enemyBuffer)
{
if (ReferenceEquals(other, self))
continue;
var away = selfPos - other.Position;
away.y = 0f;
var sqrD = away.sqrMagnitude;
var rMin = self.BodyRadius + other.BodyRadius;
if (sqrD >= rMin * rMin)
continue;
if (sqrD < 1e-8f)
continue;
var d = Mathf.Sqrt(sqrD);
var penetration = rMin - d;
var share = other.Mass / (self.Mass + other.Mass);
correction += (away / d) * (penetration * share);
overlapCount++;
}
}
}
}
_enemyHash.Query(selfPos, _enemyBuffer);
_obstacleHash.Query(selfPos, _obstacleBuffer);
앞 장에서 만들어둔 두 버퍼가 쓰이는 곳이다. 적 1000 마리면 이 루프가 1000번 도는데, 그때마다 리스트를 새로 만들지 않고 같은 두 개의 내용만 갈아 끼운다.

내 위치에서 다른 객체의 위치를 빼 away 벡터를 구하고, y = 0으로 눌러 평면벡터로 만든다. 겹침은 지면 위의 평면 문제이고, 수직 성분을 살려두면 적들이 서로를 위아래로 밀어 뜨거나 가라앉는다.
이 벡터는 두 가지를 동시에 담고 있다.
- 다른 객체로부터 멀어지는 방향
- 다른 객체와의 거리(크기)
뒤에서 이 둘은 분리해서 쓴다. 방향은 away / d로 뽑고, 크기는 penetration이 따로 정한다. 한 벡터가 방향과 크기를 겸하게 두면 "얼마나 밀어낼지"가 "얼마나 떨어져 있는지"에 끌려다니게 된다.
var sqrD = away.sqrMagnitude;
var rMin = self.BodyRadius + other.BodyRadius;
if (sqrD >= rMin * rMin)
continue;
if (sqrD < 1e-8f)
continue;
sqrMagnitude로 거리의 제곱을 구한다. magnitude는 내부에서 제곱근을 계산하는데, 해시가 넘겨준 후보 대부분은 애초에 겹쳐 있지 않다. 판별을 제곱 공간에서 하면 걸러지는 다수에게는 제곱근을 지불하지 않고, 살아남은 소수에게만 Mathf.Sqrt를 한 번 쓰게 된다. 비교식도 rMin을 제곱해 양변의 차원을 맞췄다.
- sqrD >= rMin * rMin — 두 반지름의 합보다 멀리 있으므로 겹치지 않았다.
- sqrD < 1e-8f — 겹치긴 했는데 두 중심이 사실상 같은 점에 있다. 이때 away는 영벡터에 가깝고, 밀어낼 방향이 정의되지 않는다.
두 번째 경우를 걸러내지 않으면 away / d에서 0에 가까운 값으로 나누게 된다. d가 정확히 0이면 결과는 NaN이고, 이 NaN은 correction을 거쳐 그 적의 위치에 그대로 들어간다. 한 번 위치가 NaN이 된 객체는 이후 모든 연산이 NaN을 뱉으므로 스스로 회복하지 못하고, NaN은 어떤 비교와도 false가 되기 때문에 조건문들이 조용히 통과되며 에러 하나 없이 그 적만 영원히 사라진다.
- 방향을 모르는 채로 밀어내는 것보다 이번 프레임을 건너뛰는 편이 나음.
두 객체가 겹쳐 있다면 겹친 만큼을 나눠 물러나 겹침을 해소한다.
var d = Mathf.Sqrt(sqrD);
var penetration = rMin - d;
var share = other.Mass / (self.Mass + other.Mass);
correction += (away / d) * (penetration * share);
overlapCount++;

겹침이 해소된 상태란 두 몸의 중심이 정확히 rMin만큼 떨어진 상태다. 지금 중심 거리가 d이므로 모자란 양은 penetration = rMin - d이고, 이번 쌍에서 메워야 할 거리 전부다.
누가 얼마나 물러나는가
penetration은 두 객체가 나눠 부담한다. 무거운 쪽이 덜 밀리고 가벼운 쪽이 더 밀려야 하므로, 자기 몫을 상대의 질량 비율로 정한다.
var share = other.Mass / (self.Mass + other.Mass);
분자가 상대의 질량이라는 점이 핵심이다. 상대가 무거울수록 share는 1에 가까워져 self가 거의 전부를 물러나고, 상대가 가벼우면 share가 작아져 self는 조금만 움직인다.
위 식은 두 객체(self, other)가 겹쳐있을 때, 두 Share의 합이 정확히 1이 된다.
self의 몫 + other의 몫
= other.Mass / (self.Mass + other.Mass) + self.Mass / (self.Mass + other.Mass)
= 1
반지름이 각각 0.3인 A(질량 1)와 B(질량 3)의 중심 거리가 0.4라고 하자. rMin은 0.6이므로 penetration은 0.2다.
| 질량 | share | 물러난 거리 | |
| A (self) | 1 | 3/4 = 0.75 | 0.15 |
| B (other) | 3 | 1/4 = 0.25 | 0.05 |
| 1.00 | 0.20 |
둘은 서로 반대 방향으로 물러나므로 간격은 0.20만큼 벌어지고, 새 거리는 정확히 rMin이 된다. 무게에 따라 이동량은 3배 차이가 나지만 합계는 언제나 penetration이다.
other를 이 자리에서 직접 밀지 않는 이유도 여기에 있다. other도 IRepulsionReceiver라면 자기 차례의 루프에서 반대 방향으로 자기 몫만큼 물러난다. 각자 자기 몫만 처리하면 쌍 전체는 저절로 맞아떨어진다.
단, 위 계산이 한 프레임에 딱 떨어지는 것은 다른 겹침이 없는 두 객체만 있을 때다. 여럿과 동시에 겹치면 사정이 달라진다.
방향과 크기를 합치기
correction += (away / d) * (penetration * share);
overlapCount++;
away / d는 상대로부터 멀어지는 단위 벡터, 즉 방향이다. penetration * share는 그 방향으로 물러날 거리다. 둘을 곱해 변위를 만들어 correction에 누적하고, 몇 개체와 겹쳤는지를 overlapCount에 함께 센다.
여기서 correction은 속도가 아니라 변위다. 그래서 Time.deltaTime이 곱해지지 않는다. 겹침은 시간을 두고 서서히 줄여갈 값이 아니라 이번 프레임 안에 만족시켜야 할 제약이기 때문이다.
여럿과 겹쳐있을 때
if (overlapCount > 0)
correction /= overlapCount;
겹친 상대가 여럿이면 correction에는 각 상대에 대한 요구가 모두 누적되어 있다. 하지만 이 요구들은 서로 독립적이지 않다.
- 한 상대로부터 물러나는 동작이, 곧 다른 상대로부터도 물러나는 동작이기 때문이다.
그대로 더하면 실제 필요량보다 부풀어 오르고, 필요 이상으로 밀려난 적이 다른 적을 되밀면서 떨림이 된다.
그래서 합 대신 평균을 쓴다. 평균은 부족하게 물러날 수는 있어도 과하게 물러나지는 않는다. 남은 겹침은 다음 프레임이 이어서 처리하면 되지만, 과보정은 그렇게 회수할 수 없다.
■ 장애물 처리
public class Obstacle : MonoBehaviour, IRepulsionSource, ISteeringSource
{
public Vector3 Position => transform.position;
public float InfluenceRadius => influenceRadius;
public float BodyRadius => bodyRadius;
[SerializeField] private float influenceRadius = 1f;
[SerializeField] private float bodyRadius = 0.3f;
private void OnDrawGizmos()
{
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, InfluenceRadius);
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, BodyRadius);
}
}
[Obstacle.cs]
장애물은 다른 객체를 밀 수 있고, 닿기 전부터 다른 객체의 진행 방향을 트는 객체이므로, IRepulsionSource , ISteeringSource를 구현한다.
public class EnemyRegister : MonoBehaviour
{
private void Update()
{
foreach (var self in _repulsionsReceivers)
{
// selfPos, correction, obsForce는 적 겹침 해소 foreach 내부에 선언됨.
var obsCorrection = Vector3.zero;
var obsOverlapCount = 0;
foreach (var obs in _obstacleBuffer)
{
var away = selfPos - obs.Position;
away.y = 0f;
var d = away.magnitude;
var rMin = self.BodyRadius + obs.BodyRadius;
if (d < rMin && d > 0.0001f)
{
obsCorrection += (away / d) * (rMin - d);
obsOverlapCount++;
}
if (TryCalculateSteering(away, d, obs, out var f))
{
obsForce += f;
}
}
if (obsOverlapCount > 0)
obsCorrection /= obsOverlapCount;
correction += obsCorrection;
self.ApplyRepulsion(correction, obsForce);
}
}
}
필드에 놓인 정적 장애물도 겹침을 해소하는 방식 자체는 적끼리와 같다. 다만 장애물은 두 개의 반지름을 가지므로, 하나의 장애물에 대해 겹침 해소와 조향이 함께 계산된다.

1. 장애물 겹침 해소(BodyRadius)
if (d < rMin && d > 0.0001f)
{
obsCorrection += (away / d) * (rMin - d);
obsOverlapCount++;
}
적끼리의 식과 비교하면 share가 곱해지지 않았다. 장애물은 밀려나지 않으므로 penetration 전부를 파고든 쪽이 감당해야 하기 때문이다.
- 이는 share = other.Mass / (self.Mass + other.Mass)에서 other.Mass를 무한대로 보낸 극한과 같다. 그 값은 1로 수렴하므로 곱셈 자체가 사라진다. Obstacle이 Mass 필드를 갖지 않는 이유이기도 하다.
2. 조향(InfluenceRadius)
if (TryCalculateSteering(away, d, obs, out var f))
{
obsForce += f;
}
private bool TryCalculateSteering(Vector3 away, float dist, ISteeringSource item,
out Vector3 force)
{
if (dist < 0.0001f || dist >= item.InfluenceRadius)
{
force = Vector3.zero;
return false;
}
force = (away / dist) * (1 - dist / item.InfluenceRadius);
return true;
}
방향을 구하는 방법은 같지만 크기가 다르다. 겹침 해소가 "얼마나 파고들었는가"를 크기로 삼는 반면, 조향은 (1 - dist / InfluenceRadius)라는 거리에 따른 가중치를 쓴다.
이 값은 영향 범위의 경계에서 0이고 장애물에 가까워질수록 1에 수렴한다. 경계 바깥에서는 힘이 약해지는 것이 아니라 아예 계산되지 않는다(return false). 즉 멀리 서는 아무 영향이 없다가 범위에 들어서는 순간부터 서서히 방향이 틀어지고, 가까울수록 강해진다.
▶ 장애물 조향은 평균을 내지 않는 이유
겹침 해소에 필요한 correction과 obsCorrection는 각각 겹친 수로 평균을 내지만, obsForce는 평균을 내지 않는다.
겹침 보정은 "이만큼 물러나야 한다"는 요구라 여럿을 그대로 더하면 과보정이 되지만, 조향은 "이쪽으로 비켜라"라는 방향 제안이다. 여러 장애물 사이를 지날 때는 그 제안들이 합쳐진 방향으로 가는 것이 옳고, 크기가 커지는 문제는 받는 쪽에서 상한을 걸어 처리한다
if (obsOverlapCount > 0)
obsCorrection /= obsOverlapCount;
correction += obsCorrection;
self.ApplyRepulsion(correction, obsForce);
장애물끼리도 평균을 낸다. 여러 장애물과 동시에 겹쳤을 때 요구가 중복되는 문제는 적일 때와 같기 때문이다.
중요한 것은 이 평균이 적끼리의 평균과 분리되어 있다는 점이다. 둘을 더해 전체 개수로 나누지 않고, 각각 평균을 낸 뒤 더한다.
- 하나로 합친다면, 적 열 마리와 장애물 하나에 동시에 겹친 적은 장애물이 요구한 보정의 1/11만 받기 때문. (주변이 붐빌수록 장애물을 더 깊이 파고든다.)
마지막으로 위치 보정(correction)과 조향력(obsForce)을 함께 넘긴다. 둘은 받는 쪽에서도 끝까지 분리되어 처리된다 — 하나는 위치에 직접 더해지고, 다른 하나는 속도에 실려 dt가 곱해진다.
▶ var d = away.magnitude로 바로 비교하는 이유
적끼리의 거리를 계산할 땐, 거리의 제곱(var sqrD = away.sqrMagnitude)을 사용해서 한번 거른 뒤, Sqrt를 사용했지만, 장애물은 바로 거리를 구한다.
이유는 장애물은 후보 수가 적어서 걸러낼 이득이 적고, 조향 계산이 dist를 필요로 하기 때문에 한 번은 무조건 구해야 하기 때문이다.
■ 셀 크기의 기준 반경
// 적끼리 — 두 몸 반경의 합 안에 들어와야 겹침
var rMin = self.BodyRadius + other.BodyRadius;
if (sqrD >= rMin * rMin)
continue;
겹침 판정의 기준은 한 개체의 몸 반경이 아니라, 두 몸 반경을 더한 rMin이다. 두 중심 사이의 거리가 rMin보다 작을 때만 겹침이 성립한다.
- sqrD를 rMin * rMin과 비교하는 것은 제곱근 연산을 피하기 위한 것일 뿐, 기준 거리 자체는 rMin이다.
앞에서 셀 크기는 찾으려는 반경보다 조금 크게 잡아야 한다고 했는데, 그 반경이 여기서 정해진다.

두 객체 모두 자신이 속한 공간 해싱 그리드 셀의 좌측 상단에 붙어 있다. 중심점이 셀 안 어디에 놓일지는 알 수 없으므로, 셀 크기를 정할 때는 이렇게 모서리에 붙은 최악의 경우를 기준으로 삼아야 한다.
- 그리드의 cellSize = 1, 두 객체의 bodyRadius를 각각 0.5라고 한다면,
rMin은 1이 되어 셀 크기와 같아진다. 조회 지점에서 3×3 블록의 경계까지 남은 거리는 좁은 쪽이 정확히 셀 크기 하나이므로, rMin 안에 있는 이웃의 중심점은 어느 방향에 있든 3×3 안에 들어온다. 겹침 범위에 있는 적을 하나도 놓치지 않는다.

반대로 셀 크기가 rMin보다 작으면 rMin 범위가 3×3 블록을 벗어난다. 벗어난 영역에 중심점이 등록된 적은 조회 후보에 아예 오르지 않으므로, 몸이 겹쳐 있는데도 위의 판정 코드까지 도달하지 못한다.
var cx = Mathf.FloorToInt(pos.x / _cellSize);
var cy = Mathf.FloorToInt(pos.z / _cellSize);
이런 차이가 생기는 이유는 그리드에 저장되는 값이 오로지 객체의 중심점뿐이기 때문이다. Insert는 Position 하나로 셀 인덱스를 계산해 개체를 그 셀에만 등록하고, 몸 반경은 그리드에 전혀 반영하지 않는다.
그래서 몸통이 3×3 밖으로 튀어나오는 것은 문제가 되지 않지만, 중심점이 밖에 있으면 그 개체는 조회 입장에서 존재하지 않는 것과 같다.
- rMin은 "이 거리 안의 이웃은 반드시 찾아야 한다"는 요구 반경이고, 셀 크기는 "3×3 조회가 실제로 보장해 주는 반경"
요구가 보장을 넘지 않아야 하므로 셀 크기는 rMin 이상이어야 한다. 실제로 잡아야 할 하한은 등장할 수 있는 가장 큰 rMin, 즉 가장 큰 BodyRadius의 두 배다.
// 장애물 — InfluenceRadius 안에 들어와야 조향
if (dist < 0.0001f || dist >= item.InfluenceRadius)
{
force = Vector3.zero;
return false;
}
장애물 그리드도 같은 논리를 따르지만 기준 반경이 다르다. 장애물은 겹침 해소와 조향을 함께 보는데, 겹침은 적과 마찬가지로 두 몸 반경의 합을 쓰고 조향은 InfluenceRadius 하나만 본다.
조향 범위가 몸 반경의 합보다 훨씬 넓으므로 넓은 쪽에 맞춰야 둘 다 놓치지 않는다. 두 그리드의 기준이 이렇게 다르기 때문에 EnemyRegister는 enemyHashCellSize와 obstacleHashCellSize를 각각 별도 필드로 노출한다. 두 필드 모두 int이므로 계산한 하한보다 큰 최소 정수로 올려 잡는다.
■ 전체 코드
using System.Collections.Generic;
using UnityEngine;
public class EnemyRegister : MonoBehaviour
{
[SerializeField, Min(1), Tooltip("가장 큰 적의 지름(BodyRadius × 2) 이상이어야 함")]
private int enemyHashCellSize = 1;
[SerializeField, Min(1),Tooltip("이 해시의 최대 판정 거리(influenceRadius, 적+장애물 반지름 합) 이상이어야 함")]
private int obstacleHashCellSize = 2;
[SerializeField] private Transform smallObstacleParent;
private List<Enemy> _enemyList;
private List<IRepulsionReceiver> _repulsionsReceivers;
private SpatialHash<Enemy> _enemyHash;
private SpatialHash<Obstacle> _obstacleHash;
private List<Enemy> _enemyBuffer;
private List<Obstacle> _obstacleBuffer;
private List<Enemy> _pendingRemove;
private static EnemyRegister _instance;
private static bool _isDestroy = false;
public static EnemyRegister Instance
{
get
{
if (_isDestroy)
return null;
if (_instance == null)
{
_instance = FindAnyObjectByType<EnemyRegister>();
if (_instance == null)
{
Debug.LogError($"{nameof(EnemyRegister)} not found.");
return null;
}
}
_instance.EnsureInit();
return _instance;
}
}
public void RegisterEnemy(Enemy enemy)
{
_enemyList.Add(enemy);
if(enemy is IRepulsionReceiver r)
_repulsionsReceivers.Add(r);
}
public void UnRegisterEnemy(Enemy enemy)
{
_pendingRemove.Add(enemy);
}
public void QueryForRadius(Vector3 center, float radius, List<Enemy> buffer)
{
_enemyHash.QueryForRadius(center, radius, buffer);
}
private void Awake()
{
if (_instance != null && _instance != this)
{
Debug.LogError("EnemyRegister 씬에 둘 이상 존재.");
return;
}
_isDestroy = false;
_instance = this;
EnsureInit();
}
private void Update()
{
var dt = Time.deltaTime;
_enemyHash.Clear();
FlushPending();
foreach (var e in _enemyList)
{
_enemyHash.Insert(e);
}
foreach (var self in _repulsionsReceivers)
{
var selfPos = self.Position;
_enemyHash.Query(selfPos, _enemyBuffer);
_obstacleHash.Query(selfPos, _obstacleBuffer);
var correction = Vector3.zero; // 이번 프레임에 내가 물러나야 할 변위
var overlapCount = 0; // 몇 마리와 겹쳤나(나중에 평균 낼 때 사용)
var obsForce = Vector3.zero;
foreach (var other in _enemyBuffer)
{
if (ReferenceEquals(other, self)) continue;
var away = selfPos - other.Position;
away.y = 0f;
var sqrD = away.sqrMagnitude;
var rMin = self.BodyRadius + other.BodyRadius;
if(sqrD >= rMin * rMin) continue;
if(sqrD < 1e-8f) continue;
var d = Mathf.Sqrt(sqrD);
var penetration = rMin - d;
var share = other.Mass / (self.Mass + other.Mass);
correction += (away / d) * (penetration * share);
overlapCount++;
}
if (overlapCount > 0)
correction /= overlapCount;
var obsCorrection = Vector3.zero;
var obsOverlapCount = 0;
foreach (var obs in _obstacleBuffer)
{
var away = selfPos - obs.Position;
away.y = 0;
var d = away.magnitude;
var rMin = self.BodyRadius + obs.BodyRadius;
if (d < rMin && d > 0.0001f)
{
obsCorrection += (away / d) * (rMin - d);
obsOverlapCount++;
}
if (TryCalculateSteering(away, d, obs, out var f))
{
obsForce += f;
}
}
if (obsOverlapCount > 0)
obsCorrection /= obsOverlapCount;
correction += obsCorrection;
self.ApplyRepulsion(correction, obsForce);
}
foreach (var e in _enemyList)
{
if (!e.IsDead)
e.Move(dt);
}
FlushPending();
}
private bool TryCalculateSteering(Vector3 away, float dist, ISteeringSource item,
out Vector3 force)
{
if (dist < 0.0001f || dist >= item.InfluenceRadius)
{
force = Vector3.zero;
return false;
}
force = (away / dist) * (1 - dist / item.InfluenceRadius);
return true;
}
private void FlushPending()
{
foreach (var e in _pendingRemove)
{
_enemyList.Remove(e);
if(e is IRepulsionReceiver r) _repulsionsReceivers.Remove(r);
}
_pendingRemove.Clear();
}
private void EnsureInit()
{
if (_enemyList != null) return;
_enemyList = new List<Enemy>();
_repulsionsReceivers = new List<IRepulsionReceiver>();
_pendingRemove = new List<Enemy>();
_enemyHash = new SpatialHash<Enemy>(enemyHashCellSize);
_obstacleHash = new SpatialHash<Obstacle>(obstacleHashCellSize);
_enemyBuffer = new List<Enemy>();
_obstacleBuffer = new List<Obstacle>();
if (smallObstacleParent != null)
{
for (var i = 0; i < smallObstacleParent.childCount; ++i)
{
if (smallObstacleParent.GetChild(i).TryGetComponent(out Obstacle obstacle))
{
_obstacleHash.Insert(obstacle);
}
}
}
}
private void OnDestroy()
{
if (_instance == this)
{
_instance = null;
_isDestroy = true;
}
}
}
'Unity,C# > Unity 정보' 카테고리의 다른 글
| [Unity] 다수의 적을 어떻게 움직일까(6) - 필드 몹 (0) | 2026.09.18 |
|---|---|
| [Unity] 다수의 적을 어떻게 움직일까(5) - 적 설계 (0) | 2026.09.17 |
| [Unity] 다수의 적을 어떻게 움직일까(3) - 적 관리 시스템 (0) | 2026.08.03 |
| [Unity] 다수의 적을 어떻게 움직일까(2) - 공간 해시 그리드 (0) | 2026.08.03 |
| [Unity] 다수의 적을 어떻게 움직일까(1) - Flowfield 길찾기 (0) | 2026.08.03 |