지도 한 장을 모두가 읽는다
Esc

    지도 한 장을 모두가 읽는다

    2026년 9월 18일 수정–최적화격자

    유닛 200마리가 같은 깃발로 달려갑니다. 유닛마다 길찾기를 한 번씩 돌리면 200번입니다.

    그런데 그 200번은 거의 같은 계산입니다. 출발점만 다르고 목표가 같으니, 목표 근처의 지형은 200번 다시 계산됩니다. 128×128 맵에서 재 보면 한 마리당 평균 983칸을 확정하고, 200마리면 196,659칸입니다.

    목표에서 거꾸로 센다

    방향을 뒤집으면 이 반복이 통째로 사라집니다.

    출발점에서 목표로 재는 대신 목표에서 시작해 맵 전체로 번져 나가면, 한 번에 모든 칸의 “목표까지 얼마” 가 나옵니다. 맵의 걸을 수 있는 칸이 13,249개인데, 그 한 번이 13,249칸입니다. 한 마리당 983칸씩 200번 하는 것보다 훨씬 쌉니다.

    유닛은 이제 길을 찾지 않습니다. 발밑 칸의 값과 이웃 칸의 값을 견줘 더 낮은 쪽으로 가면 됩니다. 골짜기로 굴러떨어지는 것과 같습니다. 이 지도를 흐름 필드(Flow Field) 라고 부릅니다.

    세 겹으로 나눠 둔다

    흐름 필드는 보통 세 겹으로 만들어 둡니다. 겹마다 바뀌는 시점이 다르기 때문입니다.

    겹 무엇이 들었나 언제 바뀌나
    비용 필드(cost field) 칸 하나를 밟는 값. 벽은 못 감, 늪은 비쌈 지형이 바뀔 때만
    통합 필드(integration field) 그 칸에서 목표까지의 누적 비용 목표가 바뀔 때마다
    흐름 필드(flow field) 가장 싼 이웃을 가리키는 방향 하나 통합 필드를 다시 만들 때

    비용 필드는 맵이 안 바뀌면 그대로 두고, 목표만 바뀌면 뒤의 두 겹만 다시 만듭니다. 마지막 겹이 방향 하나뿐이라는 것이 중요합니다. 유닛이 실제로 읽는 것은 이것뿐이고, 칸당 1바이트면 됩니다.

    만들어지는 것을 보면 이렇다

    숫자를 보면 물결이 목표에서 바깥으로 번져 나갑니다. 가로세로는 10, 대각선은 14 로 셉니다. 루트 2를 1.4로 친 것이고, 정수라 칸에 적어 두기 좋고 나눗셈이 없습니다.

    물결이 끝나면 칸마다 화살표가 하나씩 답니다. 그 화살표가 지도의 전부입니다. 유닛 다섯이 각자 다른 곳에서 출발하지만 아무도 길을 찾지 않습니다. 발밑 화살표만 보고 한 칸씩 갑니다.

    // 1. 목표에서 거꾸로. 칸마다 목표까지의 비용을 채운다
    void BuildIntegration(Vector2Int goal) {
        Array.Fill(cost, int.MaxValue);
        cost[Index(goal)] = 0;
    
        var open = new MinHeap();
        open.Push(goal, 0);
    
        while (open.Count > 0) {
            var cur = open.Pop();
    
            foreach (var d in Dirs8) {
                var next = cur + d;
                if (!Walkable(next)) continue;
    
                int n = cost[Index(cur)] + StepCost(d) * Terrain(next);
                if (n >= cost[Index(next)]) continue;
    
                cost[Index(next)] = n;
                open.Push(next, n);
            }
        }
    }
    
    // 2. 칸마다 가장 싼 이웃 하나를 가리키게 한다
    void BuildFlow() {
        for (int i = 0; i < cellCount; i++) {
            int best = cost[i];
            byte dir = NONE;
    
            for (byte d = 0; d < 8; d++) {
                int n = NeighborIndex(i, d);
                if (n < 0 || cost[n] >= best) continue;
                best = cost[n];
                dir = d;
            }
    
            flow[i] = dir;          // 칸 하나에 1바이트
        }
    }

    유닛 쪽 코드는 이렇게 끝납니다.

    void Update() {
        byte d = flow[Index(CellOf(transform.position))];
        if (d == NONE) return;
    
        transform.position += (Vector3)Dirs8[d] * speed * Time.deltaTime;
    }

    길찾기 호출이 없습니다. 배열 조회 하나입니다. 유닛이 몇 마리든 한 마리당 비용이 같습니다.

    몇 마리부터 값을 하나

    지도 한 장을 만드는 값은 유닛이 하나든 천이든 똑같습니다. 그래서 유닛이 적으면 손해입니다. 128×128 맵에서 유닛 수만 바꿔 재 봤습니다.

    유닛 각자 길찾기 흐름 필드 (지도 한 장 + 걷기)
    1 983 13,339 열세 배 손해
    5 4,916 13,698 아직 손해
    15 14,745 14,599 여기서 뒤집힌다
    50 49,165 17,738 2.8배
    200 196,659 31,204 6.3배
    1,000 983,295 103,024 9.5배

    열다섯 마리 근처가 손익분기입니다. 64×64 맵에서 재도 같은 자리가 나왔습니다. 지도 한 장 값을 한 마리당 길찾기 값으로 나눈 것이라, 맵이 커지면 둘 다 같이 커져서 분기점은 잘 안 움직입니다.

    유닛 서넛이 각자 다른 곳으로 가는 게임에는 쓸 이유가 없습니다. 떼로 몰려가는 장면이 있는 게임의 도구입니다.

    방향 하나로 줄여 두면 생기는 것

    마지막 겹이 칸당 1바이트라는 데서 따라오는 것들이 있습니다.

    • 메모리가 작습니다. 128×128 이면 흐름 필드가 16KB 입니다. 유닛마다 경로 배열을 들고 있는 것보다 훨씬 쌉니다
    • 유닛을 더 넣어도 공짜입니다. 새로 들어온 유닛이 자기 발밑을 읽으면 그만입니다. 길찾기 요청 큐가 필요 없습니다
    • 경로를 저장할 필요가 없습니다. 유닛이 밀려나거나 넉백을 당해도, 그 자리의 화살표를 읽으면 됩니다. 경로를 들고 있는 방식은 이럴 때 다시 찾아야 합니다
    • 비용 필드에 무엇을 넣느냐가 부대 성격이 됩니다. 적 포탑 주변 칸을 비싸게 매기면 부대가 알아서 돌아갑니다. 유닛 코드는 하나도 안 바뀝니다

    목표가 바뀌면 통째로 다시 만든다

    값을 치르는 곳입니다. 통합 필드는 목표 하나에 한 장입니다.

    • 플레이어가 깃발을 옮기면 13,249칸을 다시 채워야 합니다
    • 목표가 여럿이면 지도도 여러 장입니다. 부대가 셋이고 목적지가 다 다르면 세 장을 따로 만듭니다
    • 쫓는 대상이 움직이면 매 프레임 다시 만들어야 합니다. 이건 감당이 안 됩니다

    그래서 실제로는 이렇게 씁니다.

    • 목표를 칸 단위로 반올림합니다. 쫓는 대상이 같은 칸 안에서 움직이는 동안은 다시 안 만듭니다
    • 몇 프레임에 나눠 채웁니다. 한 프레임에 다 하지 말고 물결을 몇 번에 나눠 밀어냅니다. 그동안 유닛은 낡은 지도를 봅니다. 한두 프레임 낡은 방향은 눈에 안 띕니다
    • 지도를 낮은 해상도로 만듭니다. 칸 하나를 두 배 크게 잡으면 칸 수가 사분의 일입니다. 방향이 거칠어지는 만큼 유닛 쪽에서 보간합니다

    유닛끼리는 여전히 겹친다

    흐름 필드는 지형만 봅니다. 유닛이 어디 있는지는 모릅니다. 그래서 좁은 문 앞에서 같은 화살표를 읽은 유닛들이 그대로 겹쳐 서고, 서로를 밀고 들어갑니다.

    여기는 길찾기의 몫이 아닙니다. 흐름 필드가 준 방향에 유닛끼리 밀어내는 힘을 더해서 실제 이동 방향을 만듭니다.

    Vector2 desired = Dirs8[flow[Index(cell)]];
    Vector2 separation = SeparationFrom(neighborsWithinRadius);
    
    Vector2 move = (desired * flowWeight + separation * separationWeight).normalized;

    이 두 힘의 비율이 부대가 뭉쳐 보이느냐 흩어져 보이느냐를 정합니다. 둘을 한 곳에서 섞는다는 것만 알면 됩니다. 흐름 필드를 고칠 일이 아닙니다.

    조심할 자리

    • 대각선으로 벽 모서리를 스치는 것을 막을 거면 흐름 필드를 만들 때도 막아야 합니다. 통합 필드만 막고 방향은 그대로 두면 화살표가 벽을 뚫습니다
    • 10과 14는 근사값입니다. 긴 대각선 경로에서 실제 거리와 조금씩 어긋납니다. 최단 거리 자체가 필요한 계산에 이 값을 그대로 쓰면 안 됩니다
    • 막힌 칸은 방향이 없습니다. 벽에 둘러싸인 구역에서 출발한 유닛은 화살표가 없어 제자리에 섭니다. 그 상태를 유닛 쪽에서 따로 처리해야 합니다
    • 통합 필드를 int 로 두면 128×128 에 64KB 입니다. 맵이 커지면 ushort 로 줄이는 것이 흔합니다. 비용 상한만 확인하면 됩니다

    정리

    흐름 필드는 목표에서 거꾸로 한 번 채워 두고 모두가 읽는 방법입니다.

    • 유닛마다 길을 찾는 대신 지도를 한 장 만듭니다. 유닛 코드에는 길찾기 호출이 없고 배열 조회만 남습니다
    • 세 겹으로 나눠 둡니다. 지형이 바뀌면 첫 겹, 목표가 바뀌면 뒤의 두 겹입니다
    • 유닛 열다섯 마리 근처가 손익분기입니다. 그보다 적으면 각자 찾는 편이 쌉니다
    • 목표 하나에 지도 한 장입니다. 목표가 자주 바뀌면 이 방법의 전제가 무너집니다
    • 유닛끼리 겹치는 것은 흐름 필드가 풀어 주지 않습니다. 밀어내는 힘을 따로 얹습니다