3P by GN⁺ | ★ favorite | 댓글 1개
  • 게임 물리에서 반복되는 충돌 감지를 공 시뮬레이션으로 풀어 보며, 모든 쌍 검사에서 sweep-and-prune로 넘어가는 최적화 흐름을 설명함
  • 단순 방식은 n개 객체의 모든 후보 쌍에 intersects()를 호출해 약 (n*(n-1))/2번 검사하므로 O(n²) 로 빠르게 커짐
  • AABB 교차 테스트는 여러 부등식과 &&로 구성되며, 단락 평가와 부등식의 추이성을 이용하면 충돌 가능성이 없는 후보를 일찍 버릴 수 있음
  • 객체를 왼쪽 경계인 minimum x 기준으로 정렬한 뒤, ball2.left > ball1.right가 되는 순간 내부 루프를 break해 이후 후보를 한꺼번에 제외함
  • 정렬 비용 O(n log n) 에 x축 겹침 수 m만큼의 루프 비용을 더해 평균적으로 O(n log n + m) 수준이 되며, 불필요한 intersects() 호출이 크게 줄어듦

게임 충돌 감지의 출발점

  • 충돌 감지는 비디오 게임 프로그래밍에서 여러 동작의 전제가 됨
    • 캐릭터가 서로 통과하지 못하게 함
    • Goomba가 다른 객체와 부딪혔을 때 방향을 바꿈
    • agar.io에서 큰 세포가 작은 세포를 접촉 시 먹음
    • 일반적인 게임 물리를 처리함
  • 예시는 강체 공 시뮬레이션을 사용해 여러 충돌 감지 접근을 비교함
  • 범위는 가장 단순한 방식에서 sweep-and-prune로 이어지는 접근이며, 공간 분할이나 공간 트리 세분화는 제외함

모든 쌍을 검사하는 단순 접근

  • 가장 직접적인 방법은 모든 객체 쌍을 후보로 보는 방식임
    • 바깥 루프는 각 공을 순회함
    • 안쪽 루프는 i + 1부터 시작해 A-B와 B-A 같은 중복 쌍을 피함
    • 각 후보 쌍에 intersects(ball1, ball2)를 호출하고, 참이면 bounce(ball1, ball2)를 실행함
  • 이 검사는 매 시간 단계마다 반복되므로, 공은 충돌하는 시점에 튕김 처리됨
  • 객체 수가 적을 때는 충분하지만, 수가 늘어나면 검사량이 빠르게 성능 병목이 됨

O(n²)이 만드는 한계

  • 단순 알고리듬은 Big O 기준으로 O(n²) 시간에 실행됨
  • n개의 공에 대해 검사해야 할 쌍은 대략 (n*(n-1))/2, 즉 0.5n² - 0.5n개임
    • n = 5이면 10쌍
    • n = 10이면 45쌍
    • n = 15이면 105쌍
    • n = 20이면 190쌍
  • 모든 객체가 동시에 겹치는 최악의 경우에는 어떤 충돌 감지 알고리듬도 O(n²) 충돌 처리를 피하기 어려움
  • 실제 비교에서는 최악의 경우보다 평균 및 최선의 경우가 더 실용적임
  • 단순 방식은 실제 충돌 수와 무관하게 항상 Θ(n²) 로 움직여 개선 여지가 큼

intersects() 안의 반복 작업

  • 최적화의 출발점은 모든 후보 쌍마다 호출되는 intersects() 함수임
  • 일반적인 AABB 교차 테스트는 각 방향의 경계를 비교하는 여러 부등식 검사로 구성됨
function intersects(object1, object2) {
  // compare objects' bounds to see if they overlap
  return object1.left < object2.right
      && object1.right > object2.left
      && object1.top < object2.bottom
      && object1.bottom > object2.top;
}
  • 이 검사는 네 조건으로 나뉨
    • object1.left < object2.right
    • object1.right > object2.left
    • object1.top < object2.bottom
    • object1.bottom > object2.top
  • &&단락 평가 때문에 조건 하나라도 거짓이면 전체 교차 테스트는 즉시 거짓이 됨
  • 여러 테스트에 걸쳐 “적어도 하나의 조건이 거짓”인 경우를 일반화하면 intersects() 호출 자체를 줄일 수 있음
  • 이는 한 축에서 그림자가 겹치지 않으면 두 객체가 충돌하지 않는다는 분리축 정리와 같은 방향의 아이디어임

부등식의 추이성으로 후보 버리기

  • object1.right > object2.left 조건 하나만 보더라도 최적화 여지가 생김
  • 세 객체 A, B, C가 수평으로 A-B-C 순서에 있을 때 다음 검사가 모두 거짓일 수 있음
A.right > B.left // returns false
B.right > C.left // returns false
A.right > C.left // returns false
  • A > B가 거짓이고 B > C가 거짓이면, 부등식의 추이성으로 A > C도 거짓임을 알 수 있음
  • 따라서 intersects(A, C)를 호출하지 않아도 두 객체가 충돌하지 않는다고 판단 가능함
  • 이 생략은 객체가 특정 순서에 있을 때만 적용되지만, 객체의 레이블은 임의적이므로 왼쪽 객체를 A, 가운데를 B, 오른쪽을 C처럼 정하면 됨
  • 객체를 이런 논리적 순서로 배치하는 작업이 곧 정렬

x축 최소값 기준 정렬

  • 정렬된 리스트는 부등식의 추이성을 여러 후보에 한꺼번에 적용할 수 있게 해줌
  • 일반적인 빠른 정렬 알고리듬은 O(n log n) 이며, O(n²)보다 낮음
  • 객체는 점이 아니라 x축 구간을 차지하므로, x 위치 기준 정렬에는 왼쪽 경계인 minimum x를 사용함
  • 단순 O(n²) 코드에서 필요한 변경은 두 가지임
    • 루프 전에 sortByLeft(balls)로 공을 왼쪽 경계 x좌표 기준 정렬함
    • 내부 루프에서 ball2.left > ball1.right이면 break
// sort by min x
sortByLeft(balls);

// for each ball
for (let i = 0; i < balls.length; i++) {
  const ball1 = balls[i];
  // check each of the other balls
  for (let j = i + 1; j < balls.length; j++) {
    const ball2 = balls[j];

    // stop when too far away
    if (ball2.left > ball1.right) break;

    // check for collision
    if (intersects(ball1, ball2)) {
      bounce(ball1, ball2);
    }
  }
}
  • 정렬 함수는 왼쪽 경계 차이를 기준으로 배열을 정렬함
function sortByLeft(balls) {
  balls.sort((a,b) => a.left - b.left);
}

break가 안전한 이유

  • 리스트가 정렬되어 있으면 임의의 양의 정수 c에 대해 다음 관계가 성립함
balls[j + c].left >= balls[j].left
  • 현재 후보가 다음 조건을 만족하면 현재 쌍은 x축에서 겹치지 않음
balls[j].left > ball1.right
  • 두 부등식을 합치면 다음 관계가 됨
balls[j + c].left >= balls[j].left > ball1.right
  • 추이성에 따라 balls[j + c].left > ball1.right도 참이므로, 이후 모든 후보 역시 현재 ball1과 x축에서 겹치지 않음
  • 현재 ball2ball1과 더 이상 겹치지 않는 순간, 내부 루프의 나머지 후보는 검사하지 않고 중단할 수 있음
  • 이 최적화는 실제 intersects() 호출을 x축에서 겹치는 쌍으로 제한함

개선된 시간 복잡도

  • 정렬 비용은 mergesort나 quicksort 같은 빠른 정렬을 기준으로 O(n log n) 항을 추가함
  • 조기 중단이 있는 이중 루프는 평균적으로 O(n + m) 로 볼 수 있음
    • m은 전체 x축 겹침 수임
    • 최선의 경우 겹침이 없으면 불필요한 처리가 거의 없어 O(n)에 가까움
    • 최악의 경우에는 여전히 O(n²)까지 악화될 수 있음
  • 평균 사례는 객체가 대체로 고르게 분포하고 객체당 충돌이 몇 개만 발생하는 상황을 가정함
  • 전체 복잡도는 정렬과 루프를 합쳐 O(n log n + m)
  • 단순 방식보다 나아지는 이유는 두 가지임
    • n log n보다 작음
    • 겹침 수 m에 일부 의존하므로 필요한 것보다 더 많이 처리하지 않음

구현 부담과 다음 단계

  • 이 정렬 기반 방식은 코드 변경이 적으면서 실행 시간 성능을 크게 개선하는 균형점임
  • 비교 데모에서는 전역 모든 쌍 검사보다 정렬 기반 쌍 검사가 프레임당 intersects() 테스트 수를 눈에 띄게 줄임
  • 정렬 비용은 비교 시각화에 표시되지 않지만, 교차 테스트가 충분히 비싸다는 전제를 둠
  • 더 발전된 방식과 최종 코드는 Part 2로 이어짐

댓글과 토론

Hacker News 의견들
  • 이 방식에서 흥미로운 점은 글쓴이가 최고 성능을 위해 병합 정렬/퀵 정렬처럼 “빠른” 정렬 알고리즘을 쓰라고 제안한다는 것임
    하지만 실제로는 더 “나쁜” 정렬 알고리즘인 삽입 정렬이 더 빠를 수 있음
    충돌 감지 시스템의 객체들은 프레임 사이에서 보통 조금씩만 움직이므로, 이전 프레임의 거의 정렬된 목록을 유지할 수 있음
    이런 목록에서는 삽입 정렬이 O(n)에 가까워지는 반면, 퀵 정렬은 O(n^2)에 가까워질 수 있음

    • 글쓴이가 Part 2에서 거의 같은 내용을 다룸
      “정렬 단계는 분석상 병목인데, 대부분의 시간에는 정렬이 아무것도 하지 않는다. 목록은 거의 항상 이전 프레임에서 이미 정렬되어 있다. 정렬이 깨져도 보통 몇 번의 교환만으로 다시 정렬된다. 여기 삽입 정렬 동작 예시가 있다”는 식으로 설명함
    • 매 단계마다 정렬하는 대신, 인덱싱 구조를 조금 느슨하게 만들어 객체가 epsilon보다 덜 움직였을 때의 충돌 후보를 잡는 방법도 있음
      예를 들어 구의 반지름을 epsilon만큼 키우면 가능함
      구가 epsilon만큼 움직이지 않은 동안에는 인덱스를 다시 계산할 필요가 없음
      다시 계산해야 할 때 지연 피크를 피하려면 매 프레임 10%씩 정렬해 뒤처진 인덱스를 만들 수 있음
      10프레임 뒤에는 10프레임 전 위치에서 epsilon 이내에 있는 한 유효한 인덱스를 얻게 됨
    • 거의 정렬된 목록에서 퀵 정렬이 O(n^2)이 되는 건 피벗을 정말 나쁘게 골랐을 때뿐임
      피벗을 무작위로 고르면 O(n log n)이 되고, 이미 거의 정렬된 목록이라면 목록 가운데 원소를 피벗으로 고를 수도 있음
      다만 최적 피벗을 써도 퀵 정렬은 최선의 경우에도 O(n log n)임
      데이터 안의 오름차순/내림차순 run 개수를 k라고 할 때 O(n log k) 동작을 하는 간단한 병합 정렬 변형들이 있음
      Haskell 표준 라이브러리의 기본 sort가 그런 알고리즘을 쓰고, Python도 그럴 것 같음
  • 글 구성이 아주 좋았음
    90년대 후반부터 어떤 형태로든 게임 개발을 해왔고, 지금은 이 대부분이 엔진에 추상화되어 있지만, 복잡한 시스템 시뮬레이션이 어떻게 동작하는지 이해하는 데는 이런 내용이 필수적임
    글쓴이가 접근하기 쉬운 글을 만들어줘서 고마움

  • 연속 충돌 감지에 관해서는 이 문서를 늘 좋게 봤음: https://github.com/bepu/bepuphysics2/blob/master/Documentati...
    라이브러리 자체도 성능 면에서 훌륭함
    다만 최적화가 많이 들어가 있어서 통합하기는 조금 까다로움

  • “이 순진한 알고리즘은 빅오 기준으로 O(n2) 시간에 실행된다”는 말이 맞는지 궁금함
    바깥 루프 i는 n - 1번 돌고, 안쪽 루프 j는 i + 1에서 시작하니 점점 n - 1보다 적게 도는 것 아닌가 싶음
    전공자가 아니라서, n이 클 때 대략 O(n2) 와 같다고 보는 건지, 겉보기처럼 그보다 작은 건지 궁금함

    • 정확히 n^2는 아님
      i번째 원소에 대해 비교를 (n - i - 1)번 수행하고, 0부터 인덱싱하면 총 비교 횟수는 (n - 1) * n / 2가 됨
      https://en.wikipedia.org/wiki/Triangular_number 참고
      결국 빅오 분석에서는 차이가 없음
      빅오는 n이 무한대로 갈 때의 동작을 설명하고, 이때 이차항이 지배하기 때문임
    • 안쪽 루프를 j = i + 1에서 시작하는 “최적화”는 모든 객체 쌍을 두 번씩 검사하지 않기 위한 것임
      객체를 자기 자신과 검사하지 않게 해주는 효과도 있음
      모든 쌍을 한 번씩 검사하므로 알고리즘은 O(n^2)
    • 빅오는 입력 크기, 즉 입력 목록 길이에 따라 추상 연산 수가 어떻게 스케일하는지를 설명하는 복잡도 분류일 뿐임
      일반적으로 연산 수를 입력 크기의 함수로 해석적으로 표현할 수 있다면, 빅오는 가장 큰 항만 남기고 모든 계수를 버림
      알고리즘의 실제 성능을 꼭 설명하지는 않음
      20n2^+5n2n^2 + 9001n은 둘 다 O(n^2)임
    • 1부터 n까지 더한 합이라서 n(n+1)/2가 됨
      빅오 표기에서는 모든 계수와 더 느리게 증가하는 항을 무시하므로 이차 복잡도로 줄어듦
    • 빅오는 미적분의 극한 계산과 비슷하다고 보면 더 이해될 수도 있음
  • 일러스트 활용이 좋았고, 적절한 사용처럼 보였음
    가끔 인터랙티브 일러스트가 있는 글은 멋진 데모를 잔뜩 넣기 위한 핑계처럼 느껴지고, TED 강연처럼 알맹이보다 장식이 많은 경우가 있음
    하지만 이 글은 일러스트가 내용을 잡아먹지 않았음

  • Part 2: https://leanrada.com/notes/sweep-and-prune-2/
    다른 좋은 글들도 볼 만함: https://leanrada.com/

  • 오래전에 비슷한 걸 했는데, 정렬 대신 각 방향별 인덱스 목록을 유지하고 객체들이 스스로 정렬되게 했음
    예를 들어 objectIndicesSortedByLeftEdge/RightEdge/TopEdge/BottomEdge 같은 4개 목록이 있음
    객체가 수평으로 움직이면 leftEdge와 rightEdge 배열에서 자기 인덱스를 갱신함
    이동하더라도 대개 인덱스 1~2개만 교환하면 충분하기 때문임

    • 그 방식은 대부분 정적인 장면에 유용해 보임
      동적인 요소가 많아질수록 그래프를 다시 만드는 방식이 더 나아 보임
  • 처음 보는 방식인데, 잠재 충돌체 수를 줄이기 위해 사분 트리 같은 걸 쓰는 것과 비슷하지 않나?

    • 맞음
      다만 실시간 렌더링보다는 오프라인 렌더링에서 k-d 트리 같은 걸 더 자주 보게 됨
  • “공간 분할이나 공간 트리 세분화 같은 다른 접근법은 다루지 않겠다”는 부분이 궁금함
    글의 알고리즘이 일반적으로 공간 분할/공간 트리 세분화보다 빠른지 아는 사람이 있나?
    오래전에 공간 트리 유형의 접근을 써봤고 순진하게 보기에는 꽤 좋은 방식 같았지만, 당시가 인터넷 이전인 80년대라 다른 사람들이 쓰던 알고리즘을 조사하거나 비교해본 적은 없음

    • 공간 분할이나 트리 세분화 등을 유지하는 복잡도는, 특히 움직이는 객체가 엄청나게 많을 때 큰 부담이 될 수 있음
      단일 엔티티 목록 하나나, 각각 엔티티 목록을 담는 256x256 셀 격자를 관리하는 쪽이 객체가 움직일 때마다 모든 트리 불변식을 유지해야 하는 복잡한 분할 구조보다 작성·디버깅·최적화하기 훨씬 쉬움
      DOOM이나 Quake 시절에는 이런 기반 시스템의 성능이 지금보다 훨씬 중요했기 때문에, 엔진 작성자들이 매우 복잡한 분할 시스템을 만드는 게 더 타당했을 것임
      요즘 CPU는 정렬된 배열을 훑는 데 매우 강하고, 파이프라이닝 때문에 연결 리스트나 트리를 따라가는 작업은 상대적으로 예전보다 덜 유리함
      CPU 시간은 엔티티 목록 관리보다 AI, 렌더링 같은 곳에 더 많이 쓰이게 됨