움직이는 것들에는 트리를 짓지 않는다
Esc

    움직이는 것들에는 트리를 짓지 않는다

    –최적화자료구조

    BVH에서 상자만 고쳐 가며 120프레임을 돌렸더니 레이 비용이 아홉 배가 됐습니다. 쿼드트리는 물체가 칸을 옮길 때마다 쪼개고 합쳐야 했고, KD 트리는 아예 정적 데이터 전용이었습니다.

    네 편이 같은 모양입니다. 구조를 먼저 짓고, 질의를 던지고, 움직이면 구조가 상한다.

    그런데 게임에서 충돌 판정은 질의가 아닙니다. 매 프레임 전부 대 전부를 물어보는 일입니다. 물체 1000개면 쌍이 499,500개고, 이걸 60번씩 해야 합니다. 질의가 매 프레임 한 번뿐이라면, 구조를 짓는 값을 그 한 번에 다 치르는 셈입니다.

    질문을 바꿔 봅니다. 구조를 안 짓고 줄만 세워 두면 어떨까요.

    한 축에 눕히면 구간이 된다

    물체의 경계 상자를 x축에만 투영하면 구간 하나가 됩니다. [왼쪽 끝, 오른쪽 끝] 입니다.

    여기서 쓸 수 있는 사실이 하나 있습니다. 두 물체가 겹친다면 두 구간도 반드시 겹칩니다. 뒤집으면 이렇게 됩니다.

    구간이 안 겹치면 물체도 절대 안 겹친다.

    그러면 구간이 겹치는 쌍만 추려 놓고, 그 안에서만 진짜 판정을 하면 됩니다. 구간이 겹치는 쌍을 찾는 방법은 줄을 세우는 것입니다.

    모든 물체의 시작점과 끝점을 한 줄에 늘어놓고 왼쪽부터 지나갑니다. 시작점을 만나면 그 물체를 열린 목록에 넣고, 끝점을 만나면 뺍니다. 새로 여는 물체는 그때 열려 있는 것들과만 짝을 짓습니다.

    이것이 스윕 앤 프룬(sweep and prune) 입니다. 소트 앤 스윕이라고도 부릅니다.

    물체 여덟 개면 모든 쌍이 28개인데 9개만 판정합니다. 물체 1000개로 키워 보면 이렇습니다.

    방법 판정한 쌍 전부 대비
    전부 다 (n²/2) 499,500 100%
    스윕 앤 프룬 (x축) 13,188 2.6%
    격자 (칸 25) 1,396 0.3%
    격자 (칸 50) 3,006 0.6%

    진짜로 겹친 쌍은 409개입니다. 2.6퍼센트까지 줄었지만 여전히 필요한 것의 32배를 판정합니다. 그리고 격자가 더 적습니다. 이 표만 보면 스윕 앤 프룬을 쓸 이유가 없습니다.

    한 축만으로는 모자라다

    13,188과 409 사이의 간격이 어디서 나오는지는 분명합니다. x 구간이 겹쳐도 y가 멀리 떨어져 있으면 물체는 안 겹칩니다. 한 축만 봤기 때문입니다.

    무엇까지 보나 남는 쌍
    아무것도 안 봄 499,500
    x 구간만 13,188
    x 로 추린 뒤 y 까지 409

    y까지 보면 정확히 진짜 겹친 쌍만 남습니다. 축에 나란한 상자라면 두 축이 전부니까요.

    그래서 실제 구현은 두 단계입니다. 한 축으로 추리고, 남은 쌍마다 온전한 상자 판정을 한 번 합니다. 상자 판정은 비교 네 번이라 싸고, 판정할 쌍이 13,188개면 부담이 안 됩니다.

    축을 여러 개 유지하는 방법도 있습니다. 뒤에서 다시 보겠습니다.

    어느 축으로 눕힐까

    축을 잘못 고르면 전부 무너집니다. 물체 1000개를 전부 바닥 근처에 붙여 놓고 재 봤습니다. 가로로 긴 2.5D 게임이나, 지면 위에 유닛이 늘어선 장면이 이 모양입니다.

    방법 판정한 쌍 전부 대비
    스윕 앤 프룬 (x축) 9,457 1.9%
    스윕 앤 프룬 (y축) 205,809 41.2%
    격자 (칸 25) 15,759 3.2%
    격자 (칸 50) 23,934 4.8%

    스물두 배 차이입니다. y축으로 눕히면 모든 구간이 서로 겹쳐서, 열린 목록에 거의 전부가 들어앉습니다. 전부 대 전부로 돌아가는 것과 다를 게 없습니다.

    고르는 방법은 간단합니다. 물체 중심의 분산이 가장 큰 축을 씁니다. 위 장면의 분산은 x가 80,275, y가 133입니다. 고민할 것도 없습니다. 분산은 한 번 훑으면 나오니, 몇 초에 한 번씩 다시 재서 축을 갈아타도 됩니다.

    여기서는 스윕 앤 프룬이 격자를 이겼습니다. 격자는 칸이 정사각형이라 납작한 장면에서 세로로 텅 빈 칸을 계속 만들지만, 스윕은 축 하나만 잘 고르면 되니까요.

    진짜 값어치는 다시 정렬하지 않는 데 있다

    여기까지는 한 프레임 이야기입니다. 스윕 앤 프룬이 실제로 쓰이는 이유는 다음 프레임에 있습니다.

    물체가 한 프레임에 움직이는 거리는 자기 크기에 비하면 아주 작습니다. 그러면 줄 세운 순서도 거의 그대로입니다. 천 개를 다시 정렬할 이유가 없습니다. 값만 갱신하고 삽입 정렬을 돌리면, 어긋난 자리 몇 개만 고치고 끝납니다.

    거의 정렬된 배열에서 삽입 정렬은 자리바꿈 횟수에 비례하는 값만 씁니다. 이것을 시간적 일관성이라고 부릅니다.

    물체 1000개를 120프레임 동안 움직이면서 프레임당 평균을 재 봤습니다. 한 축만 유지한 경우입니다.

    프레임당 이동 거리 자리바꿈 짝 추가 짝 삭제
    0.5 1,133 282 283
    2.0 4,518 1,126 1,126
    8.0 18,132 4,522 4,521

    물체가 천천히 움직이면 프레임당 자리바꿈이 1,133번입니다. 천 개를 처음부터 정렬하면 비교가 대략 9,965번이고, 후보 쌍을 매 프레임 다시 훑으면 13,188번입니다.

    거꾸로 빠르게 움직이면 값어치가 사라집니다. 프레임당 8픽셀이면 자리바꿈이 18,132번으로, 차라리 다시 정렬하는 게 낫습니다. 시간적 일관성은 공짜로 주어지는 게 아니라 장면의 성질입니다. 순간이동이 잦은 게임이라면 이 구조를 쓸 이유가 없습니다.

    자리 한 번에 짝 하나

    자리바꿈 횟수보다 중요한 것이 있습니다. 자리를 한 번 바꿀 때 무슨 일이 일어나는가 입니다.

    줄에는 시작점과 끝점 두 종류만 있습니다. 이웃한 둘이 자리를 바꾸는 경우는 네 가지인데, 그중 둘은 아무 의미가 없고 둘만 뜻이 있습니다.

    • 끝점을 시작점이 앞질렀다: 저쪽이 끝나기 전에 이쪽이 시작했다는 뜻입니다. 짝이 하나 생깁니다
    • 시작점을 끝점이 앞질렀다: 저쪽이 시작하기 전에 이쪽이 끝났다는 뜻입니다. 짝이 하나 사라집니다
    • 시작점끼리, 끝점끼리 바뀐 것은 겹침 관계를 바꾸지 않습니다

    그래서 목록을 처음부터 다시 만들 필요가 없습니다. 자리를 바꿀 때마다 짝 하나를 넣거나 빼면 끝입니다. 위 표의 “짝 추가·삭제” 가 그 횟수입니다.

    이것을 두 축에 다 적용하면 재미있는 일이 벌어집니다. 짝마다 몇 개 축에서 겹쳤는지를 세어 두고, 그 값이 2가 되는 순간을 잡는 것입니다.

    프레임당 이동 거리 자리바꿈 (두 축 합) 짝 추가 짝 삭제 들고 있는 짝
    0.5 2,277 15.4 15.5 401
    2.0 9,082 60.1 60.3 385

    들고 있는 짝이 진짜로 겹친 쌍과 정확히 같습니다. 후보가 아니라 답입니다. 그리고 프레임당 바뀌는 것이 열다섯 개뿐입니다.

    이게 격자와 갈리는 지점입니다. 격자는 매 프레임 후보 쌍 1,396개를 다시 만듭니다. 그중 무엇이 새로 겹치기 시작했고 무엇이 막 떨어졌는지 알려면 지난 프레임 목록과 따로 비교해야 합니다. 스윕 앤 프룬은 그 두 사건이 알고리즘 안에서 그냥 나옵니다.

    충돌 시스템이 필요로 하는 게 대개 그 두 사건입니다. “지금 닿아 있다” 보다 “방금 닿기 시작했다” 와 “방금 떨어졌다” 를 알아야 소리를 내고, 데미지를 한 번만 주고, 트리거를 켜고 끕니다.

    값이 똑같을 때

    직접 만들면서 걸린 자리가 하나 있습니다. 끝점 값이 정확히 같을 때입니다.

    한 물체의 시작이 다른 물체의 끝과 같은 값이면, 정렬에서 둘 중 무엇을 앞에 놓을지가 정해지지 않습니다. 그런데 겹침 판정은 보통 시작 < 끝 처럼 등호를 빼고 씁니다. 맞닿기만 한 것은 안 겹친 것으로 봅니다.

    이 둘이 어긋나면 목록이 진실과 조금씩 달라집니다. 저는 여덟 개짜리 장면에서 이걸 만났습니다. 한 프레임 만에 짝 하나가 빠졌고, 자리바꿈으로는 되살아나지 않았습니다. 이미 순서가 맞아 있어서 바꿀 일이 없었기 때문입니다.

    고치는 법은 한 줄입니다. 값이 같으면 끝점을 먼저 놓습니다. 그러면 맞닿은 것은 “저쪽이 끝난 뒤에 이쪽이 시작” 으로 읽히고, 판정의 등호 규칙과 맞아떨어집니다.

    들고 있는 목록이 진실과 어긋나는 버그는 몇 프레임 뒤에 엉뚱한 자리에서 터집니다. 그래서 만들 때 매 프레임 전부 다시 계산한 것과 대조하는 검산을 켜 두고 돌려 보세요. 위 측정도 전부 그렇게 확인한 값입니다.

    약점

    첫째, 큰 물체 하나가 전부를 망칩니다. 맵을 가로지르는 물체는 구간도 맵을 가로지르니, 열린 목록에서 내내 안 나갑니다. 그러면 그 뒤에 여는 모든 물체와 짝이 됩니다.

    맵을 가로지르는 물체 판정한 쌍
    0개 13,188
    1개 14,188
    5개 18,198
    20개 33,378

    스무 개면 2퍼센트인데 판정할 쌍이 2.5배가 됩니다. 하나당 정확히 1000쌍씩 늘어납니다. 바닥 콜라이더나 벽처럼 크고 안 움직이는 것은 아예 다른 목록에 두는 편이 낫습니다.

    둘째, 고르게 흩어진 장면에서는 격자에 집니다. 후보 쌍도 갱신 값도 그렇습니다.

    프레임당 이동 거리 스윕 자리바꿈 (한 축) 격자 목록 조작 (칸 25)
    0.5 1,133 95
    2.0 4,518 375
    8.0 18,132 1,418

    격자는 물체가 칸 경계를 넘을 때만 목록을 건드립니다. 천천히 움직이면 프레임당 62개만 바뀝니다. 숫자만 보면 격자가 모든 줄에서 이깁니다.

    그래서 정리하면 이렇습니다.

    • 고르게 흩어져 있고 크기가 비슷하면 격자입니다. 이 시리즈에서 네 번째로 같은 결론입니다
    • 한 축으로 납작한 장면이면 스윕입니다. 격자는 빈 칸을 세로로 계속 만듭니다
    • 칸 크기를 정할 수 없거나 세계가 무한하면 스윕입니다. 고를 값이 축 하나뿐입니다
    • 겹치기 시작한 순간과 떨어진 순간이 필요하면 스윕입니다. 그 두 사건이 공짜로 나옵니다

    어디에 쓰나

    물리 엔진의 브로드페이즈가 가장 흔한 자리입니다. 쌍을 추려서 정밀 판정으로 넘기는 단계를 그렇게 부릅니다. 3차원이면 축 세 개로 늘리고, 짝마다 겹친 축 수를 세어 3이 되는 것만 올립니다.

    정렬해 두고 시간적 일관성으로 유지한다는 발상 자체는 충돌 밖에서도 씁니다.

    • 그리기 순서. 2D 게임에서 y 값으로 정렬해 앞뒤를 정하는 것도 매 프레임 거의 안 바뀝니다
    • 반투명 정렬. 카메라에서 먼 것부터 그려야 하는데, 카메라가 조금 움직이면 순서도 조금 바뀝니다
    • 시야 목록. 거리순으로 세워 두고 가까운 것부터 갱신하는 구조도 같은 성질을 씁니다

    공통점은 답을 새로 구하지 않고 지난 답을 고친다는 것입니다. 매 프레임 처음부터 구하는 게 당연해 보이는 자리에서, 지난 프레임의 답이 이미 거의 정답이라면 고치는 쪽이 항상 쌉니다. 얼마나 쌀지는 얼마나 안 바뀌는지가 정합니다.

    정리

    스윕 앤 프룬은 구조를 짓지 않고 줄만 세워 두는 방법입니다.

    • 한 축에 눕히면 구간이 됩니다. 구간이 안 겹치면 물체도 안 겹칩니다
    • 한 축만으로는 13,188쌍이 남고, 두 축을 다 보면 409쌍입니다. 추리고 나서 온전한 판정을 한 번 합니다
    • 축을 잘못 고르면 스물두 배가 나빠집니다. 중심의 분산이 큰 축을 쓰세요
    • 프레임마다 다시 정렬하지 않습니다. 삽입 정렬로 어긋난 자리만 고치면 됩니다
    • 자리 한 번에 짝 하나가 생기거나 사라집니다. 두 축을 유지하면 프레임당 바뀌는 게 열다섯 개뿐이었습니다
    • 값이 같을 때 끝점을 먼저 놓으세요. 아니면 목록이 조용히 진실과 어긋납니다
    • 맵을 가로지르는 물체는 따로 빼세요. 하나가 1000쌍씩 늘립니다
    • 고르게 흩어진 장면에서는 격자가 더 낫습니다. 스윕의 자리는 납작한 장면과 바뀐 것만 알고 싶을 때입니다