좌표를 칸으로, 칸을 배열로
Esc

    좌표를 칸으로, 칸을 배열로

    –최적화격자자료구조

    공간을 칸으로 나눠 두면 가까운 것만 본다는 발상은 한 줄로 끝납니다. 그런데 막상 만들어 보면 계산이 아니라 구현에서 막힙니다.

    • 맵 가운데를 원점으로 잡았더니 원점 근처에서만 판정이 이상합니다
    • 맵이 얼마나 커질지 몰라서 배열 크기를 정할 수가 없습니다
    • 두 물체가 부딪혔다는 신호가 두 번씩 옵니다

    셋 다 같은 자리에서 나옵니다. 좌표를 칸으로 바꾸고, 칸을 자료구조에 앉히는 자리입니다.

    좌표를 칸 번호로 바꾸는 한 줄

    칸 번호는 좌표를 칸 크기로 나누면 나옵니다. 이렇게 쓰고 싶어집니다.

    int cx = (int)(x / cellSize);   // 여기서 갈립니다

    (int) 캐스팅은 0 쪽으로 자릅니다. 양수에서는 내림과 같지만 음수에서는 올림이 됩니다. 칸 크기가 1일 때 무슨 일이 생기는지 보면 이렇습니다.

    x (int)(x / 1) FloorToInt(x / 1)
    1.5 1 1
    0.5 0 0
    -0.5 0 -1
    -1.5 -1 -2

    -0.5 와 0.5 가 같은 칸이 됩니다. 원점을 낀 두 칸이 하나로 붙어서 그 자리만 칸이 두 배로 넓어집니다. 맵 구석을 원점으로 잡으면 음수 좌표가 없으니 안 터지고, 가운데를 원점으로 잡는 순간 터집니다. “왜 맵 가운데서만 이상하지” 가 여기서 나옵니다.

    int cx = Mathf.FloorToInt(x / cellSize);

    칸 크기를 2의 거듭제곱으로 잡을 수 있다면 나눗셈 대신 시프트를 씁니다.

    int cx = (int)x >> shift;       // cellSize == 1 << shift

    산술 시프트는 음수에서도 내림입니다. 빠른 길인데 마침 안전한 길이기도 합니다. 다만 (int)x 캐스팅이 앞서 있으니, 좌표가 실수라면 그 캐스팅에서 같은 문제가 되돌아옵니다. 시프트를 쓸 거면 좌표를 먼저 정수로 만드는 자리를 확인하세요.

    맵 밖을 다루려면 배열을 버린다

    칸 번호가 나왔으면 그 칸의 목록을 찾아야 합니다. 가장 쉬운 방법은 2차원 배열입니다. 그런데 배열은 맵 크기를 미리 알아야 만들 수 있습니다.

    맵 칸 크기 2 칸 수
    100×100 50×50 2,500
    1,000×1,000 500×500 250,000
    10,000×10,000 5,000×5,000 2,500만

    오픈월드나 절차적으로 늘어나는 맵에서는 아예 못 정합니다. 물체가 1,000개뿐인데 칸 2,500만 개를 들고 있는 것도 말이 안 됩니다.

    그래서 칸 번호를 해시해서 정해진 개수의 버킷에 넣습니다. 공간 해시(spatial hash)입니다.

    int Hash(int cx, int cy)
        => ((cx * 73856093) ^ (cy * 19349663)) & (bucketCount - 1);

    두 큰 소수를 곱해 XOR 하는 이 조합은 공간 해시에서 흔히 쓰는 것입니다. 버킷 수를 2의 거듭제곱으로 잡으면 나머지 연산 대신 & 로 끝납니다.

    여기서 해시 충돌은 걱정거리가 아닙니다. 일반적인 해시 테이블과 다른 점입니다.

    멀리 떨어진 두 칸이 같은 버킷에 들어와도, 어차피 꺼낸 다음에 거리를 재서 거릅니다. 후보가 조금 늘어날 뿐 답이 틀리지는 않습니다. 그래서 충돌 처리를 따로 안 해도 됩니다. 버킷 수만 물체 수와 비슷하게 잡아 두면 됩니다.

    반대로 칸 번호를 버킷에 같이 저장해 두지 않으면 안 됩니다. 꺼낸 것이 정말 그 칸의 물체인지 확인할 방법이 없어집니다. 거리로 거르니 결과는 맞지만, 엉뚱한 칸의 물체까지 매번 거리 계산을 하게 됩니다.

    같은 쌍을 두 번 보지 않기

    자기 칸과 이웃 여덟 칸을 보면 A 와 B 의 쌍을 두 번 봅니다. A 에서 B 를 한 번, B 에서 A 를 한 번입니다. 충돌 신호가 두 번 오는 이유가 이것입니다.

    아홉 칸을 다 보면 나 절반만 보면 나 같은 쌍을 두 번 본다 점선 넷은 상대 쪽에서 본다
    이웃을 절반만 보면 같은 쌍을 두 번 세지 않는다. 빠뜨리는 쌍은 없다.

    이웃을 절반만 보면 됩니다. 자기 칸 안에서는 j > i 인 쌍만 보고, 이웃은 오른쪽·왼쪽아래·아래·오른쪽아래 넷만 봅니다.

    static readonly (int dx, int dy)[] Half = { (1, 0), (-1, 1), (0, 1), (1, 1) };
    
    for (int i = 0; i < ids.Count; i++) {
        for (int j = i + 1; j < ids.Count; j++)   // 같은 칸
            Test(ids[i], ids[j]);
    
        foreach (var (dx, dy) in Half)            // 이웃 절반
            foreach (var other in CellAt(cx + dx, cy + dy))
                Test(ids[i], other);
    }

    빠뜨리는 쌍은 없습니다. 위쪽 네 칸과의 쌍은 그 칸이 자기 차례일 때 봅니다. 1,000개를 뿌려 재 보면 검사 횟수가 3,554번에서 1,777번으로 정확히 절반이 됩니다.

    칸이 반경보다 작으면 겹을 늘려야 한다

    이웃 한 겹, 즉 아홉 칸을 보는 것은 칸 크기가 검사 반경보다 클 때만 맞습니다. 칸이 반경보다 작으면 한 겹으로는 반경을 못 덮어서 가까운 쌍을 놓칩니다.

    봐야 하는 겹 수는 이렇게 정해집니다.

    겹 수 k = ceil(검사 반경 / 칸 크기)
    보는 칸 = (2k + 1)²

    칸을 잘게 쪼개면 거리 검사는 줄지만 훑어야 하는 칸이 제곱으로 늘어납니다. 100×100 맵에 물체 1,000개를 뿌리고 검사 반경을 2로 두고 재 봤습니다. 찾아낸 가까운 쌍은 646개로 전부 같고, 드는 일만 다릅니다.

    칸 크기 겹 수 보는 칸 칸 훑기 거리 검사 합
    0.5 4 81 39,560 1,016 40,576
    1.0 2 25 11,316 1,250 12,566
    2.0 1 9 3,184 1,777 4,961
    4.0 1 9 1,940 6,821 8,761
    8.0 1 9 672 25,514 26,186
    25.0 1 9 64 201,004 201,068

    칸 크기를 검사 반경에 맞춘 자리가 바닥입니다. 양쪽으로 멀어지면 여덟 배, 마흔 배씩 불어납니다. 왼쪽 절반은 칸을 도느라 비싸지고 오른쪽 절반은 거리를 재느라 비싸집니다.

    참고로 전부 대 전부로 비교하면 499,500번입니다. 바닥값의 백 배입니다.

    칸마다 리스트를 두지 않는다

    남은 것은 그릇입니다. 칸마다 리스트를 하나씩 두는 것이 가장 먼저 떠오릅니다.

    List<int>[] cells;              // 이렇게 하고 싶어지지만

    이러면 칸 수만큼 리스트를 할당하고, 매 프레임 전부 Clear() 하고, 칸을 훑을 때마다 다른 곳에 흩어진 메모리를 따라갑니다. 칸이 수천 개면 그 자체가 부담이고, 리스트가 늘어날 때마다 새 배열을 잡습니다.

    배열 세 개면 같은 일을 할 수 있습니다. 세고, 더하고, 담습니다.

    // 1. 칸마다 몇 개인지 센다
    Array.Clear(count, 0, count.Length);
    for (int i = 0; i < n; i++) count[CellOf(pos[i])]++;
    
    // 2. 앞에서부터 더해서 각 칸이 쓸 자리의 시작을 정한다
    int sum = 0;
    for (int c = 0; c < cellCount; c++) {
        start[c] = sum;
        sum += count[c];
    }
    start[cellCount] = sum;
    
    // 3. 시작 자리부터 차례로 담는다
    Array.Copy(start, cursor, cellCount);
    for (int i = 0; i < n; i++) items[cursor[CellOf(pos[i])]++] = i;

    이제 한 칸의 물체는 배열에서 붙어 있습니다.

    for (int k = start[c]; k < start[c + 1]; k++) {
        int id = items[k];
        // ...
    }

    얻는 것이 여럿입니다.

    • 할당이 없습니다. 배열 세 개를 한 번 잡아 두고 매 프레임 덮어씁니다. GC 가 볼 것이 없습니다
    • 순서대로 읽습니다. 한 칸의 물체가 붙어 있으니 캐시가 잘 맞습니다
    • 잡 시스템에 올릴 수 있습니다. 리스트가 없으니 네이티브 배열로 그대로 옮겨집니다
    • 매 프레임 통째로 다시 지어도 쌉니다. 물체가 칸을 옮겼는지 따질 필요가 없습니다

    값도 있습니다. 비용이 물체 수에 칸 수를 더한 만큼입니다. 누적합은 물체가 몇 개든 칸 수만큼 돕니다. 칸이 물체보다 훨씬 많으면 그 부분이 커집니다. 공간 해시로 버킷 수를 물체 수에 맞춰 두는 것이 여기서도 값을 합니다.

    정리

    균일 격자는 발상이 아니라 경계에서 막힙니다.

    • 칸 번호는 내림으로 구합니다. (int) 캐스팅은 음수에서 0 쪽으로 잘려서 원점 근처 두 칸을 붙여 놓습니다
    • 맵 크기를 못 정하면 배열 대신 해시로 버킷에 넣습니다. 거리로 거르니 해시 충돌은 답을 안 깨뜨립니다
    • 이웃은 절반만 봅니다. 나머지 절반은 상대 칸이 자기 차례에 봅니다. 검사가 정확히 절반이 됩니다
    • 봐야 하는 겹 수는 ceil(반경 / 칸 크기) 입니다. 칸을 반경에 맞춘 자리가 바닥이고 양쪽으로 멀어지면 급하게 비싸집니다
    • 그릇은 리스트 배열 대신 세고, 더하고, 담는 배열 세 개로 만듭니다. 할당이 없고 한 칸이 메모리에서 붙어 있습니다