[Unity] 다수의 적을 어떻게 움직일까(7) - 방향장 보간 처리

2026. 9. 18. 00:45·Unity,C#/Unity 정보
728x90

■ 기존 방향장의 문제점

방향장의 방향 읽기

방향장의 경계 근처에 있는 적이 다른 적들과의 겹침 해소 보정에 밀려 옆 셀로 넘어가면, 진행 방향이 불연속으로 꺾인다.

 

넘어간 셀의 방향이 원래 셀로 돌아오는 성분을 가지고 있다면 적은 곧바로 복귀하고, 복귀한 뒤에는 다시 밀려 경계를 넘어간다. 이 과정이 매 프레임 반복되며, 적이 밀집하는 병목 구간에서 경계에 걸친 적들은 심하게 떨린다.

계단 함수

이러한 일이 일어나는 근본적인 원인은 방향장이 위치에 대한 계단 함수이기 때문이다.

  • 계단 함수 : 정의역 구간별로 나누어져 각 구간에 일정한 상수값을 반환하는 함수.

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이 영벡터에 가까운 경우에는 정규화가 성립하지 않으므로 별도 처리가 필요하다. 이런 상황은 두 가지 경우에 발생한다.

  1. 네 표본이 모두 탈락해 아무것도 누적되지 않은 경우.
  2. 서로 마주보는 방향의 표본들이 상쇄되어 합이 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]

728x90

'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
'Unity,C#/Unity 정보' 카테고리의 다른 글
  • [Unity] 다수의 적을 어떻게 움직일까(6) - 필드 몹
  • [Unity] 다수의 적을 어떻게 움직일까(5) - 적 설계
  • [Unity] 다수의 적을 어떻게 움직일까(4) - 겹침 해소 · 장애물 조향
  • [Unity] 다수의 적을 어떻게 움직일까(3) - 적 관리 시스템
브라더스톤
브라더스톤
유티니, C#과 관련한 여러 정보를 끄적여둔 블로그입니다. Email : dkavmdk98@gmail.com
  • 브라더스톤
    젊은 프로그래머의 슬픔
    브라더스톤
  • 전체
    오늘
    어제
    • 개발 노트 (69)
      • Unity,C# (44)
        • Unity 정보 (17)
        • 알고리즘 (11)
        • 자료구조 (3)
        • 절차적생성(PCG) (13)
      • 게임수학 (16)
      • C++ (8)
        • 자료구조 (8)
      • 게임 (1)
        • 리치마작 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    벡터
    최단경로찾기
    외적
    이진공간분할
    CustomWindow
    c++
    절차적생성
    자료구조
    unity
    게임수학
    절차적던전생성
    절차적지형생성
    알고리즘
    C#
    BSP
    정렬알고리즘
    pcg
    커스텀 윈도우
    스택
    PerlinNoise
  • 최근 댓글

  • 최근 글

  • 250x250
  • hELLO· Designed By정상우.v4.10.3
브라더스톤
[Unity] 다수의 적을 어떻게 움직일까(7) - 방향장 보간 처리
상단으로

티스토리툴바