■ 기존 방향장의 문제점

방향장의 경계 근처에 있는 적이 다른 적들과의 겹침 해소 보정에 밀려 옆 셀로 넘어가면, 진행 방향이 불연속으로 꺾인다.
넘어간 셀의 방향이 원래 셀로 돌아오는 성분을 가지고 있다면 적은 곧바로 복귀하고, 복귀한 뒤에는 다시 밀려 경계를 넘어간다. 이 과정이 매 프레임 반복되며, 적이 밀집하는 병목 구간에서 경계에 걸친 적들은 심하게 떨린다.

이러한 일이 일어나는 근본적인 원인은 방향장이 위치에 대한 계단 함수이기 때문이다.
- 계단 함수 : 정의역 구간별로 나누어져 각 구간에 일정한 상수값을 반환하는 함수.
UpdateFlowField는 _searchDir의 8방향 중 하나를 각 셀에 할당하므로 방향은 45° 단위로 양자화되고, 인접한 두 셀의 방향이 다르다면 그 차이는 최소 45°다. 셀 경계를 넘는 한 프레임에 이 차이가 통째로 적용된다.
■ 방향장 함수 개선
방향장이 위치에 대한 계단 함수이기 때문에 앞의 문제가 발생했다. 따라서 방향장을 위치에 대한 연속 함수로 만들어야 한다.

방향장은 같은 셀 안이라면 어디에 있든 같은 방향을 반환한다. 셀이라는 면적 전체가 하나의 값을 공유하는 구조다. 하지만 실제로 필요한 것은 적(파란색 점)이 서 있는 그 지점의 방향이다.
이를 위해 각 셀의 방향이 셀 전체가 아니라 중점 한 곳에 있는 표본이라고 해석을 바꾼다. 보간은 값을 아는 지점 사이를 채우는 계산이므로, 값이 면이 아니라 점 위에 정의되어 있어야 하기 때문이다.
- 중점으로 하는 이유는, 셀을 대표하는 값이 셀 안에서 치우치치 않아야 하기 때문.
이렇게 해석하면 방향 표본은 셀 중심에 놓이는데, 기존 셀 격자는 좌하단 기준이라 정수가 셀 경계에 놓인다. 두 격자는 정확히 반 칸 차이나므로, 셀 경계 기준 좌표에서 0.5를 빼면 표본이 정수 위치에 오는 좌표계가 된다.
1. 중심 격자 단위로 표현
public Vector3 GetSmoothedDirection(Vector3 pos)
{
if (_flowField == null) return Vector3.zero;
var cx = (pos.x - _originCellPos.x) / _cellSize - 0.5f;
var cz = (pos.z - _originCellPos.z) / _cellSize - 0.5f;
// 중심 격자 단위로 표현된 현재 위치
var gridPos = new Vector2(cx, cz);
}
월드 좌표를 셀 크기로 나눠 격자 단위로 바꾸고, 0.5를 빼서 표본 좌표로 옮긴다. _originCellPos가 (0,0) 셀의 좌하단이므로 나눗셈까지의 결과는 정수가 셀 경계에 놓이는 좌표이고, 여기서 반 칸을 빼면 정수가 셀 중심에 놓인다.
표본과 적의 위치를 같은 좌표계에서 다뤄야 둘 사이의 비율을 계산할 수 있으므로, 적의 위치도 같은 변환을 거친다.
- gridPos의 y가 월드의 z축에 대응한다. 방향장이 XZ 평면 위의 2D 격자이기 때문이다.
2. 표본 구하기
public Vector3 GetSmoothedDirection(Vector3 pos)
{
// 중심 격자 단위 표현 생략...
// 어느 두 표본인가?
var x0 = Mathf.FloorToInt(gridPos.x);
var y0 = Mathf.FloorToInt(gridPos.y);
// 그 표본 사이에 어디에 위치해있는가?
var tx = gridPos.x - x0;
var ty = gridPos.y - y0;
}

보간을 하려면 먼저 적의 위치를 감싸는 표본을 구해야 한다. 표본 좌표에서는 표본이 정수 위치에 놓이므로, 소수점을 버림하면 왼쪽 표본의 인덱스가 나온다. 오른쪽 표본은 그 다음 정수다.
여기서 x0는 적이 서 있는 셀이 아닐 수 있다. 그림의 적(파란 점)은 두 번째 셀 안에 있지만, 그 셀의 중점보다 왼쪽에 있으므로 x0는 첫 번째 셀을 가리킨다.
- 적을 감싸는 표본이 첫 번째 셀과 두 번째 셀의 중점이기 때문이다.
tx는 두 표본 사이에서 오른쪽으로 얼마나 왔는지를 나타내는 0 이상 1 미만의 값이다. 버림한 값을 다시 빼는 것이므로 소수부만 남는다. 그림에서 x0부터 적까지의 거리가 tx에 해당한다.
- x축과 y축에 같은 처리를 하므로 표본은 2×2로 네 개가 된다.
Mathf.FloorToInt 를 사용하는 이유
Mathf.FloorToInt는 음수 좌표를 위해 필요하다. _originCellPos가 (0,0) 셀의 좌하단이므로, 적이 (0,0) 셀의 왼쪽 절반에 있으면 gridPos.x는 -0.5 이상 0 미만이 된다.
(int) 캐스팅은 0 방향으로 잘라내므로 -0.2를 0으로 만들고, tx가 음수가 되어 가중치가 무너진다. FloorToInt는 음의 무한대 방향으로 내림하므로 -0.2에서 x0는 -1, tx는 0.8이 되어 관계가 유지된다.
3. 가중치 구하기
var sum = Vector3.zero;
for (var dx = 0; dx <= 1; ++dx)
{
for (var dy = 0; dy <= 1; ++dy)
{
var x = x0 + dx;
var y = y0 + dy;
if(!InBounds(x, y)) continue;
if(!IsPassable(x, y)) continue;
if(_flowField[x,y].Direction == Vector2Int.zero) continue;
// 가중치 구하기
// dx == 0 이면 왼쪽 표본. 내가 오른쪽으로 tx 만큼 갔다면,
// 왼쪽에서 그만큼 멀어진거니 지분은 1-tx.
var wx = (dx == 0) ? 1f - tx : tx;
var wy = (dy == 0) ? 1f - ty : ty;
// 최종 가중치
var w = wx * wy;
var d = _flowField[x, y].Direction;
sum += new Vector3(d.x, 0f, d.y).normalized * w;
}
}
이중 반복문으로 (x0, y0), (x0+1, y0), (x0, y0+1), (x0+1, y0+1) 네 표본을 순회하며, 각 표본의 방향에 가중치를 곱해 누적한다. 위치가 격자 범위 밖이거나, 통행 불가능하거나, 방향이 영벡터라면 건너뛴다.
- 영벡터라면 어느 쪽이던 방향 정보가 없으므로, 섞으면 결과가 0쪽으로 끌려간다.
방향을 보간할 때는 가까이 있는 표본의 방향에 더 큰 지분을 줘야 한다. 가까울수록 그 표본이 적의 위치를 더 잘 대표하기 때문이다. 선형 보간은 이 관계를 직선으로 두어, 지분이 거리에 비례해 줄어들도록 한다.
var wx = (dx == 0) ? 1f - tx : tx;
var wy = (dy == 0) ? 1f - ty : ty;

tx는 왼쪽 표본에서 오른쪽으로 온 비율인 동시에, 오른쪽 표본과 가까운 정도를 나타낸다. 하나의 값이 두 의미를 겸하므로 tx가 그대로 오른쪽 표본의 지분이 되고, 왼쪽 표본은 나머지인 1 - tx를 갖는다.
- tx가 0.8이라면 왼쪽 지분은 0.2, 오른쪽 지분은 0.8이다. 각 축의 지분 합은 항상 1이 된다.
dx는 지금 순회 중인 표본이 어느 쪽인지 고르는 스위치 역할이며, 삼항 연산자는 그에 맞는 지분을 선택한다. dy와 wy도 같은 구조로 아래쪽과 위쪽 표본을 다룬다.

// 최종 가중치
var w = wx * wy;
var d = _flowField[x, y].Direction;
sum += new Vector3(d.x, 0f, d.y).normalized * w;
각 표본은 x축으로도 y축으로도 특정 쪽에 속하므로, 두 조건을 동시에 만족해야 큰 지분을 갖는다. 곱셈이 이 성질을 만족한다. 어느 한 축에서라도 멀면 그만큼 지분이 깎이며, 덧셈으로는 한 축만 가까워도 큰 값이 나와 성립하지 않는다.
- 기하학적으로는 네 표본이 이루는 사각형을 적의 위치에서 잘랐을 때 대각선 반대편 조각의 면적에 해당한다.
.normalized는 대각 방향의 크기를 보정한다. _flowField의 방향은 Vector2Int로 저장되어 있어 축 방향은 길이 1이지만 대각 방향은 길이 √2다. 정규화하지 않고 누적하면 대각 방향 표본이 가중치와 무관하게 약 1.41배의 영향력을 갖게 된다.
4. 결과 반환과 예외 처리
if (sum.sqrMagnitude < 1e-6f)
{
var v2D = GetCurrentCellDirection(pos);
return new Vector3(v2D.x, 0f, v2D.y).normalized;
}
return sum.normalized;
누적된 sum을 정규화해 반환한다. 정규화하는 이유는 방향만 사용하고 크기는 사용하지 않기 때문이다. 호출부인 SwarmEnemy.Move에서 이동 거리는 _moveSpeed와 speedScale이 결정하므로, 방향 벡터의 크기가 이동에 개입해서는 안 된다.
정규화 덕분에 표본이 탈락하는 경우도 자연스럽게 처리된다. 벽에 인접한 위치에서는 네 표본 중 일부가 continue로 걸러지므로 남은 가중치의 합이 1보다 작아지지만, 크기를 버리기 때문에 결과에는 영향이 없다.
- 대신 방향이 살아남은 표본 쪽으로 치우치는데, 이는 통행 가능한 방향으로 기울어지는 것이므로 오히려 유리하게 작동한다.
sum이 영벡터에 가까운 경우에는 정규화가 성립하지 않으므로 별도 처리가 필요하다. 이런 상황은 두 가지 경우에 발생한다.
- 네 표본이 모두 탈락해 아무것도 누적되지 않은 경우.
- 서로 마주보는 방향의 표본들이 상쇄되어 합이 0에 수렴한 경우.
이때는 기존의 셀 단위 조회인 GetCurrentCellDirection으로 대체한다. 보간 이전의 계단 함수로 돌아가는 것이지만, 방향이 영벡터가 되면 적이 그 자리에 멈춰버리므로 최소한의 방향을 확보하기 위한 처리다.
■ 전체 코드
public Vector3 GetSmoothedDirection(Vector3 pos)
{
if (_flowField == null) return Vector3.zero;
var cx = (pos.x - _originCellPos.x) / _cellSize - 0.5f;
var cz = (pos.z - _originCellPos.z) / _cellSize - 0.5f;
// 중심 격자 단위로 표현된 현재 위치
var gridPos = new Vector2(cx, cz);
// 어느 두 표본인가?
var x0 = Mathf.FloorToInt(gridPos.x);
var y0 = Mathf.FloorToInt(gridPos.y);
// 그 표본 사이에 어디에 위치해있는가?
var tx = gridPos.x - x0;
var ty = gridPos.y - y0;
var sum = Vector3.zero;
for (var dx = 0; dx <= 1; ++dx)
{
for (var dy = 0; dy <= 1; ++dy)
{
var x = x0 + dx;
var y = y0 + dy;
if (!InBounds(x, y)) continue;
if (!IsPassable(x, y)) continue;
if (_flowField[x, y].Direction == Vector2Int.zero) continue;
// 가중치 구하기
// dx == 0이면 왼쪽 표본. 내가 오른쪽으로 tx 만큼 갔다면,
// 왼쪽에서 그만큼 멀어진거니 지분은 1-tx.
var wx = (dx == 0) ? 1f - tx : tx;
var wy = (dy == 0) ? 1f - ty : ty;
// 최종 가중치
var w = wx * wy;
var d = _flowField[x, y].Direction;
sum += new Vector3(d.x, 0f, d.y).normalized * w;
}
}
if (sum.sqrMagnitude < 1e-6f)
{
var v2D = GetCurrentCellDirection(pos);
return new Vector3(v2D.x, 0f, v2D.y).normalized;
}
return sum.normalized;
}
private bool InBounds(int x, int y) => x >= 0 && x < _width && y >= 0 && y < _height;
[FlowField.cs]
public class SwarmEnemy : Enemy, IRepulsionReceiver
{
public override void Move(float dt)
{
var f = FlowField.Instance;
if (f == null)
return;
var pos = transform.position;
var step = _moveSpeed * dt;
if (f.IsBlocked(pos))
{
using (s_TransformMove.Auto())
transform.position += ToFlowVector(f.GetCurrentCellDirection(pos))
* step;
return;
}
var (dir, speedScale) = GetDesiredMove(pos, f);
var clampedObsForce = Vector3.ClampMagnitude(_obstacleForce, maxObsForce);
// 1단계 : 조향 - dt 있음
var steerDir = (dir + clampedObsForce).normalized;
transform.position += SlideAlongWalls(pos, steerDir * (step * speedScale), f);
// 2단계 : 겹침 해소 - dt 없음
var pos2 = transform.position;
transform.position += SlideAlongWalls(pos2, _correction * correctionStiffens, f);
if(dir.sqrMagnitude > 0.0001f)
RotationMoveDir(dir, dt);
}
/// dir = 단위벡터 또는 영벡터(크기를 태우지 말 것), speedScale = 0~1
protected virtual (Vector3 dir, float speedScale) GetDesiredMove(Vector3 pos, FlowField f)
{
return (f.GetSmoothedDirection(pos), 1f);
}
}
[SwarmEnemy.cs]
'Unity,C# > Unity 정보' 카테고리의 다른 글
| [Unity] 다수의 적을 어떻게 움직일까(6) - 필드 몹 (0) | 2026.09.18 |
|---|---|
| [Unity] 다수의 적을 어떻게 움직일까(5) - 적 설계 (0) | 2026.09.17 |
| [Unity] 다수의 적을 어떻게 움직일까(4) - 겹침 해소 · 장애물 조향 (0) | 2026.09.17 |
| [Unity] 다수의 적을 어떻게 움직일까(3) - 적 관리 시스템 (0) | 2026.08.03 |
| [Unity] 다수의 적을 어떻게 움직일까(2) - 공간 해시 그리드 (0) | 2026.08.03 |