- Na física de jogos, a detecção de colisão repetida é explicada com uma simulação de bolas, mostrando o fluxo de otimização que vai da verificação de todos os pares ao sweep-and-prune
- A abordagem simples chama
intersects() para todos os pares candidatos entre n objetos, fazendo cerca de (n*(n-1))/2 verificações, então cresce rapidamente para O(n²)
- O teste de interseção AABB é composto de várias desigualdades e
&&; usando avaliação de curto-circuito e a transitividade das desigualdades, é possível descartar cedo candidatos sem chance de colisão
- Depois de ordenar os objetos pela borda esquerda, o minimum x, no momento em que
ball2.left > ball1.right o loop interno faz break, excluindo de uma vez os candidatos seguintes
- Somando o custo de ordenação O(n log n) com o custo do loop proporcional ao número
m de sobreposições no eixo x, o desempenho médio fica em torno de O(n log n + m), reduzindo bastante chamadas desnecessárias a intersects()
O ponto de partida da detecção de colisão em jogos
- A detecção de colisão é a base de vários comportamentos na programação de videogames
- impede que personagens atravessem uns aos outros
- faz um Goomba mudar de direção ao bater em outro objeto
- no agar.io, uma célula maior come uma menor ao encostar nela
- processa a física geral do jogo
- O exemplo usa uma simulação de bolas rígidas para comparar várias abordagens de detecção de colisão
- O escopo vai da forma mais simples até a abordagem de sweep-and-prune, deixando de fora particionamento espacial ou refinamentos com árvores espaciais
A abordagem ingênua de verificar todos os pares
- O método mais direto trata todo par de objetos como candidato
- o loop externo percorre cada bola
- o loop interno começa em
i + 1 para evitar pares duplicados como A-B e B-A
- para cada par candidato, chama
intersects(ball1, ball2) e, se for verdadeiro, executa bounce(ball1, ball2)
- Essa verificação se repete a cada passo de tempo, então as bolas recebem o tratamento de quique no momento em que colidem
- Quando há poucos objetos, isso é suficiente, mas conforme o número cresce, a quantidade de verificações rapidamente vira um gargalo de desempenho
O limite imposto por O(n²)
- O algoritmo ingênuo executa em tempo O(n²) segundo a notação Big O
- Para
n bolas, o número de pares a verificar é aproximadamente (n*(n-1))/2, isto é, 0.5n² - 0.5n
n = 5 resulta em 10 pares
n = 10 resulta em 45 pares
n = 15 resulta em 105 pares
n = 20 resulta em 190 pares
- No pior caso, quando todos os objetos se sobrepõem ao mesmo tempo, praticamente nenhum algoritmo de detecção de colisão consegue evitar um custo de processamento de colisões em O(n²)
- Em comparações reais, os casos médio e melhor costumam ser mais úteis do que o pior caso
- A abordagem ingênua sempre se comporta como Θ(n²) independentemente do número real de colisões, então há bastante espaço para melhorar
O trabalho repetido dentro de intersects()
- O ponto de partida da otimização é a função
intersects(), chamada para cada par candidato
- Um teste de interseção AABB típico é composto por várias verificações de desigualdade comparando os limites em cada direção
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;
}
- Esse teste se divide em quatro condições
object1.left < object2.right
object1.right > object2.left
object1.top < object2.bottom
object1.bottom > object2.top
- Por causa da avaliação de curto-circuito de
&&, se qualquer condição for falsa, o teste inteiro de interseção retorna falso imediatamente
- Generalizando, ao longo de vários testes, os casos em que “pelo menos uma condição é falsa”, dá para reduzir o próprio número de chamadas a
intersects()
- Isso segue a mesma linha de ideia do teorema do eixo separador: se as projeções não se sobrepõem em um eixo, os dois objetos não colidem
Descartando candidatos com a transitividade das desigualdades
- Só de olhar para a condição
object1.right > object2.left, já aparece espaço para otimização
- Se três objetos A, B e C estiverem horizontalmente na ordem A-B-C, as verificações abaixo podem todas retornar falso
A.right > B.left // returns false
B.right > C.left // returns false
A.right > C.left // returns false
- Se
A > B é falso e B > C é falso, então, pela transitividade das desigualdades, sabemos que A > C também é falso
- Portanto, mesmo sem chamar
intersects(A, C), já dá para concluir que os dois objetos não colidem
- Essa omissão só se aplica quando os objetos estão em uma certa ordem, mas como os rótulos dos objetos são arbitrários, basta escolher o da esquerda como A, o do meio como B e o da direita como C
- O trabalho de colocar os objetos nessa ordem lógica é justamente a ordenação
Ordenando pelo valor mínimo no eixo x
- Uma lista ordenada permite aplicar a transitividade das desigualdades a vários candidatos de uma vez
- Um algoritmo de ordenação rápido típico roda em O(n log n), menor que O(n²)
- Como os objetos ocupam um intervalo no eixo x, e não apenas um ponto, a ordenação pela posição x usa a borda esquerda, o minimum x
- No código ingênuo O(n²), as mudanças necessárias são duas
- antes dos loops, usar
sortByLeft(balls) para ordenar as bolas pela coordenada x da borda esquerda
- no loop interno, fazer
break quando ball2.left > ball1.right
// 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);
}
}
}
- A função de ordenação organiza o array pela diferença entre as bordas esquerdas
function sortByLeft(balls) {
balls.sort((a,b) => a.left - b.left);
}
Por que o break é seguro
- Se a lista está ordenada, então para qualquer inteiro positivo
c, vale a relação abaixo
balls[j + c].left >= balls[j].left
- Se o candidato atual satisfaz a condição a seguir, então o par atual não se sobrepõe no eixo x
balls[j].left > ball1.right
- Combinando as duas desigualdades, chegamos à relação seguinte
balls[j + c].left >= balls[j].left > ball1.right
- Pela transitividade,
balls[j + c].left > ball1.right também é verdadeiro, então todos os candidatos posteriores também não se sobrepõem a ball1 no eixo x
- No instante em que o
ball2 atual deixa de se sobrepor a ball1, o restante dos candidatos do loop interno pode ser interrompido sem verificação adicional
- Essa otimização limita as chamadas reais a
intersects() aos pares que se sobrepõem no eixo x
Complexidade de tempo melhorada
- O custo de ordenação adiciona um termo O(n log n), assumindo uma ordenação rápida como mergesort ou quicksort
- O loop duplo com parada antecipada pode ser visto, em média, como O(n + m)
m é o número total de sobreposições no eixo x
- no melhor caso, sem sobreposições, quase não há trabalho desnecessário, ficando perto de O(n)
- no pior caso, ainda pode degradar para O(n²)
- O caso médio assume que os objetos estão distribuídos de forma razoavelmente uniforme e que cada objeto colide com apenas alguns outros
- Somando ordenação e loops, a complexidade total é O(n log n + m)
- Há duas razões para isso melhorar em relação à abordagem ingênua
n log n é menor que n²
- como depende em parte do número de sobreposições
m, o algoritmo evita processar muito além do necessário
Custo de implementação e próximo passo
- Essa abordagem baseada em ordenação é um bom equilíbrio entre poucas mudanças no código e grande ganho de desempenho em tempo de execução
- Na demo comparativa, a verificação de pares baseada em ordenação reduz visivelmente o número de testes
intersects() por frame em comparação com a verificação global de todos os pares
- O custo da ordenação não aparece na visualização comparativa, mas parte-se da premissa de que os testes de interseção são caros o bastante para compensar isso
- Abordagens mais avançadas e o código final continuam na Part 2
1 comentários
Comentários no Hacker News
O ponto interessante dessa abordagem é que o autor sugere usar algoritmos de ordenação “rápidos”, como merge sort/quick sort, para obter o melhor desempenho
Mas, na prática, um algoritmo de ordenação “pior”, como insertion sort, pode ser mais rápido
Os objetos em sistemas de detecção de colisão normalmente se movem só um pouco entre um frame e outro, então dá para manter a lista quase ordenada do frame anterior
Nessas listas, o insertion sort fica próximo de O(n), enquanto o quick sort pode se aproximar de O(n^2)
Ele explica algo como: “A etapa de ordenação é, na análise, o gargalo, mas na maior parte do tempo a ordenação não faz nada. A lista quase sempre já vem ordenada do frame anterior. Mesmo quando sai de ordem, normalmente bastam algumas trocas para ordená-la de novo. Aqui está um exemplo do insertion sort em ação”
Por exemplo, isso pode ser feito aumentando o raio da esfera em epsilon
Enquanto a esfera não se mover epsilon, não é preciso recalcular o índice
Para evitar picos de latência quando for preciso recalcular, dá para ordenar 10% por frame e criar um índice defasado
Depois de 10 frames, você terá um índice ainda válido, desde que a posição de 10 frames atrás esteja a até epsilon da posição atual
Se o pivô for aleatório, ele fica em O(n log n), e, se a lista já estiver quase ordenada, também dá para escolher o elemento do meio como pivô
Ainda assim, mesmo com o pivô ideal, o quick sort continua sendo O(n log n) no melhor caso
Há variações simples de merge sort que rodam em O(n log k), onde k é o número de runs crescentes/decrescentes nos dados
O
sortpadrão da biblioteca do Haskell usa um algoritmo desse tipo, e imagino que o Python tambémA estrutura do texto ficou muito boa
Trabalho com desenvolvimento de jogos de alguma forma desde o fim dos anos 90, e hoje a maior parte disso está abstraída pelas engines, mas esse tipo de conteúdo continua sendo essencial para entender como simulações de sistemas complexos funcionam
Obrigado ao autor por ter escrito algo tão acessível
Sobre detecção contínua de colisão, sempre achei este documento muito bom: https://github.com/bepu/bepuphysics2/blob/master/Documentati...
A biblioteca em si também é excelente em termos de desempenho
Só que ela é tão otimizada que a integração acaba sendo um pouco complicada
Fiquei na dúvida se a frase “esse algoritmo ingênuo executa em O(n2) no critério de big-O” está correta
O loop externo i roda n - 1 vezes, e o loop interno j começa em i + 1, então parece que ele roda cada vez menos do que n - 1 vezes
Não sou da área, então queria entender se, quando n é grande, isso é tratado aproximadamente como O(n2), ou se na prática é algo menor do que aparenta
Para o i-ésimo elemento, a comparação é feita (n - i - 1) vezes e, usando índice começando em 0, o total de comparações vira (n - 1) * n / 2
Veja https://en.wikipedia.org/wiki/Triangular_number
No fim, isso não faz diferença na análise assintótica
Big-O descreve o comportamento quando n tende ao infinito, e nesse caso o termo quadrático domina
j = i + 1serve para evitar verificar todos os pares de objetos duas vezesIsso também impede que um objeto seja comparado com ele mesmo
Como todos os pares são verificados exatamente uma vez, o algoritmo é O(n^2)
Em geral, se der para expressar analiticamente o número de operações como uma função do tamanho da entrada, o big-O mantém apenas o maior termo e descarta todos os coeficientes
Ele não descreve necessariamente o desempenho real do algoritmo
20n2^+5ne2n^2 + 9001nsão ambos O(n^2)Na notação big-O, todos os coeficientes e os termos que crescem mais devagar são ignorados, então isso se reduz a complexidade quadrática
O uso das ilustrações foi muito bom e pareceu bem dosado
Às vezes, textos com ilustrações interativas parecem mais uma desculpa para encher de demos chamativas e acabam ficando, como uma palestra TED, mais ornamentados do que substanciais
Mas neste texto as ilustrações não engoliram o conteúdo
Parte 2: https://leanrada.com/notes/sweep-and-prune-2/
Outros bons textos também valem a leitura: https://leanrada.com/
Fiz algo parecido há muito tempo, mas em vez de ordenar eu mantinha listas de índices para cada direção e deixava os próprios objetos se ordenarem
Por exemplo, havia 4 listas como
objectIndicesSortedByLeftEdge/RightEdge/TopEdge/BottomEdgeQuando um objeto se movia horizontalmente, ele atualizava seu próprio índice nos arrays leftEdge e rightEdge
Mesmo com movimento, normalmente bastava trocar 1 ou 2 posições de índice
À medida que aumenta a quantidade de elementos dinâmicos, reconstruir o grafo parece uma solução melhor
É a primeira vez que vejo essa abordagem, mas isso não é parecido com usar algo como uma quadtree para reduzir o número de colisores potenciais?
Só que, mais do que em renderização em tempo real, é mais comum ver coisas como k-d trees em renderização offline
Fiquei curioso com a parte em que ele diz que não vai tratar de “outras abordagens, como particionamento espacial ou subdivisão por árvores espaciais”
Alguém sabe se o algoritmo do texto costuma ser mais rápido que particionamento espacial/subdivisão por árvores espaciais?
Usei uma abordagem desse tipo de árvore espacial há muito tempo e, olhando de forma ingênua, parecia uma solução bem boa, mas na época era nos anos 80, antes da internet, então nunca pesquisei nem comparei com os algoritmos usados por outras pessoas
Gerenciar uma única lista de entidades, ou até uma grade de células 256x256 em que cada célula contém sua própria lista de entidades, é muito mais fácil de escrever, depurar e otimizar do que uma estrutura de partição complexa que precisa manter todas as invariantes da árvore sempre que os objetos se movem
Na época de DOOM e Quake, o desempenho desses sistemas de base era muito mais crítico do que hoje, então fazia mais sentido que os autores de engines criassem sistemas de partição extremamente complexos
Hoje os CPUs são muito bons em varrer arrays ordenados e, por causa de pipelining, seguir linked lists ou árvores é relativamente menos vantajoso do que antes
O tempo de CPU tende a ser mais gasto em coisas como IA e renderização do que no gerenciamento de listas de entidades