Detección de colisiones usando el teorema del eje de separación
() translation by (you can also view the original English article)
El teorema del eje de separación se usa a menudo para verificar las colisiones entre dos polígonos simples, o entre un polígono y un círculo. Al igual que con todos los algoritmos, tiene sus fortalezas y sus debilidades. En este tutorial, repasaremos las matemáticas detrás del teorema y mostraremos cómo se puede usar en el desarrollo de juegos con algunos ejemplos de código y demostraciones.
Nota: Aunque las demostraciones y el código fuente de este tutorial utilizan Flash y AS3, debería poder usar las mismas técnicas y conceptos en casi cualquier entorno de desarrollo de juegos.
Lo que dice el teorema
El Teorema del eje de separación (SAT para abreviar) establece esencialmente que si puedes trazar una línea para separar dos polígonos, entonces no chocan. Es así de simple.



En el diagrama anterior, puedes ver fácilmente las colisiones que ocurren en la segunda fila. Sin embargo, si intenta comprimir una línea entre las formas, fallará. La primera fila es exactamente lo opuesto. Puede dibujar fácilmente una línea para separar las formas, y no solo una, sino muchas de ellas:

Está bien, no exageremos esto; Creo que entiendes el punto. El argumento clave aquí es que si puede dibujar una línea de este tipo, entonces debe haber un espacio que separa las formas. Entonces, ¿cómo comprobamos esto?
Proyección a lo largo de un eje arbitrario

Supongamos por ahora que los polígonos a los que nos referimos son cuadrados: box1
a la izquierda y box2
a la derecha. Es fácil ver que estos cuadrados están separados horizontalmente. Un enfoque directo para determinar esto en el código es calcular la distancia horizontal entre los dos cuadrados, luego restar los medios anchos de box1
y box2
:
1 |
//Pseudo code to evaluate the separation of box1 and box2
|
2 |
var length:Number = box2.x - box1.x; |
3 |
var half_width_box1:Number = box1.width*0.5; |
4 |
var half_width_box2:Number = box2.width*0.5; |
5 |
|
6 |
var gap_between_boxes:Number = length - half_width_box1 - half_width_box2; |
7 |
if(gap_between_boxes > 0) trace("It's a big gap between boxes") |
8 |
else if(gap_between_boxes == 0) trace("Boxes are touching each other") |
9 |
else if(gap_between_boxes < 0) trace("Boxes are penetrating each other") |
¿Qué pasa si las cajas no están bien orientadas?



Aunque la evaluación de la brecha sigue siendo la misma, tendremos que pensar en otro enfoque para calcular la longitud entre los centros y las medias anchuras, esta vez a lo largo del eje P
. Aquí es donde las matemáticas vectoriales son útiles. Proyectaremos los vectores A y B a lo largo de P para obtener las medias anchuras.
Vamos a hacer una revisión de matemáticas.
Revisión del vector matematico
Comenzaremos por recapitular la definición del producto punto entre dos vectores A
y B
:

Podemos definir el producto de puntos usando solo los componentes de los dos vectores:
\[
\begin{bmatrix}A_x \\A_y\end{bmatrix}.
\begin{bmatrix}B_x \\B_y\end{bmatrix}=
(A_x)(B_x)+(A_y)(B_y)
\]
Alternativamente, podemos entender el producto de puntos usando las magnitudes de los vectores y el ángulo entre ellos:
\[
\begin{bmatrix}A_x \\A_y\end{bmatrix}.
\begin{bmatrix}B_x \\B_y\end{bmatrix}=
A_{magnitude}*B_{magnitude}*cos(theta)
\]
Ahora, intentemos averiguar la proyección del vector A
sobre P
.

Refiriéndonos al diagrama anterior, sabemos que el valor de proyección es \ (A_ {magnitud} * cos (theta) \) (donde theta
es el ángulo entre A y P). Aunque podemos seguir adelante y calcular este ángulo para obtener la proyección, es complicado. Necesitamos un enfoque más directo:
\[
A. P=A_{magnitude}*P_{magnitude}*cos(theta)\\
A.\frac{P}{P_{magnitude}}=A_{magnitude}*cos(theta)\\
\begin{bmatrix}A_x \\A_y\end{bmatrix}.
\begin{bmatrix}P_x/P_{magnitude} \\P_y/P_{magnitude}\end{bmatrix}=
A_{magnitude}*cos(theta)
\]
Tenga en cuenta que \ (\ begin {bmatrix} P_x / P_ {magnitud} \\ P_y / P_ {magnitud} \ end {bmatrix} \) es en realidad el vector unitario de P.
Ahora, en lugar de usar el lado derecho de la ecuación, como lo estábamos haciendo, podemos optar por el lado izquierdo y seguir llegando al mismo resultado.
Aplicación a un escenario
Antes de continuar, me gustaría aclarar la convención de nomenclatura utilizada para denotar las cuatro esquinas de ambas casillas. Esto se reflejará en el código más adelante:



Nuestro escenario es el siguiente:



Digamos que ambas cajas están orientadas a 45 ° del eje horizontal. Debemos calcular las siguientes longitudes para determinar la brecha entre las cajas.
- Proyección de A en el eje P
- Proyección de B en el eje P
- Proyección de C en el eje P
Tome nota especial de las direcciones de las flechas. Mientras que la proyección de A y C en P dará un valor positivo, la proyección de B en P producirá un valor negativo ya que los vectores apuntan en direcciones opuestas. Esto se trata en la línea 98 de la implementación de AS3 a continuación:
1 |
var dot10:Point = box1.getDot(0); |
2 |
var dot11:Point = box1.getDot(1); |
3 |
|
4 |
var dot20:Point = box2.getDot(0); |
5 |
var dot24:Point = box2.getDot(4); |
6 |
|
7 |
//Actual calculations
|
8 |
var axis:Vector2d = new Vector2d(1, -1).unitVector; |
9 |
var C:Vector2d = new Vector2d( |
10 |
dot20.x - dot10.x, |
11 |
dot20.y - dot10.y |
12 |
)
|
13 |
var A:Vector2d = new Vector2d( |
14 |
dot11.x - dot10.x, |
15 |
dot11.y - dot10.y |
16 |
)
|
17 |
var B:Vector2d = new Vector2d( |
18 |
dot24.x - dot20.x, |
19 |
dot24.y - dot20.y |
20 |
)
|
21 |
var projC:Number = C.dotProduct(axis) |
22 |
var projA:Number = A.dotProduct(axis); |
23 |
var projB:Number = B.dotProduct(axis); |
24 |
|
25 |
var gap:Number = projC - projA + projB; //projB is expected to be a negative value |
26 |
if (gap > 0) t.text = "There's a gap between both boxes" |
27 |
else if (gap > 0) t.text = "Boxes are touching each other" |
28 |
else t.text = "Penetration had happened." |
Aquí hay una demostración usando el código anterior. Haga clic y arrastre el punto central rojo de ambos cuadros y vea los comentarios interactivos.
Para la fuente completa, revise DemoSAT1
.as en la descarga de la fuente.
Los defectos
Bueno, podemos ir con la implementación anterior. Pero hay algunos problemas, permítanme señalarlos:
Primero, los vectores A y B son fijos. Entonces, cuando intercambias las posiciones de box1
y box2
, la detección de colisión falla.



En segundo lugar, solo evaluamos la brecha en un eje, por lo que situaciones como la que se muestra a continuación no se evaluarán correctamente:

Aunque la demostración anterior es defectuosa, aprendimos de ella el concepto de proyección. A continuación, vamos a mejorarlo.
Resolviendo el primer defecto
Primero que todo, necesitaremos obtener las proyecciones de esquinas mínimas y máximas (específicamente los vectores desde el origen hasta las esquinas de las cajas) en P.



El diagrama de arriba muestra la proyección de las esquinas mínima y máxima en P cuando las cajas están bien orientadas a lo largo de P.
Pero, ¿qué pasa si box1
y box2
no están orientados en consecuencia?



El diagrama de arriba muestra cuadros que no están bien orientados a lo largo de P, y sus correspondientes proyecciones min-max. En esta situación, tendremos que recorrer cada esquina de cada casilla y seleccionar las correctas según corresponda.
Ahora que tenemos las proyecciones min-max, evaluaremos si las cajas están chocando entre sí. Como?



Al observar el diagrama anterior, podemos ver claramente la representación geométrica para la proyección de box1.max
y box2.min
sobre el eje P.
Como puede ver, cuando hay un espacio entre los dos cuadros, box2.min-box1.max
será más que cero, o en otras palabras, box2.min> box1.max
. Cuando se intercambian las posiciones de las cajas, box1.min> box2.max
implica que hay una brecha entre ellas.
Traduciendo esta conclusión en código, obtenemos:
1 |
//SAT: Pseudocode to evaluate the separation of box1 and box2
|
2 |
if(box2.min>box1.max || box1.min>box2.max){ |
3 |
trace("collision along axis P happened") |
4 |
}
|
5 |
else{ |
6 |
trace("no collision along axis P") |
7 |
}
|
Codigo inicial
Echemos un vistazo a un código más detallado para resolver esto. Tenga en cuenta que el código AS3 aquí no está optimizado. Aunque es largo y descriptivo, la ventaja es que puedes ver cómo funcionan las matemáticas detrás de ellas.
En primer lugar, tenemos que preparar los vectores:
1 |
//preparing the vectors from origin to points
|
2 |
//since origin is (0,0), we can conveniently take the coordinates
|
3 |
//to form vectors
|
4 |
var axis:Vector2d = new Vector2d(1, -1).unitVector; |
5 |
var vecs_box1:Vector.<Vector2d> = new Vector.<Vector2d>; |
6 |
var vecs_box2:Vector.<Vector2d> = new Vector.<Vector2d>; |
7 |
|
8 |
for (var i:int = 0; i < 5; i++) { |
9 |
var corner_box1:Point = box1.getDot(i) |
10 |
var corner_box2:Point = box2.getDot(i) |
11 |
|
12 |
vecs_box1.push(new Vector2d(corner_box1.x, corner_box1.y)); |
13 |
vecs_box2.push(new Vector2d(corner_box2.x, corner_box2.y)); |
14 |
}
|
A continuación, obtenemos la proyección min-max en box1
. Puedes ver un enfoque similar usado en box2
:
1 |
//setting min max for box1
|
2 |
var min_proj_box1:Number = vecs_box1[1].dotProduct(axis); |
3 |
var min_dot_box1:int = 1; |
4 |
var max_proj_box1:Number = vecs_box1[1].dotProduct(axis); |
5 |
var max_dot_box1:int = 1; |
6 |
|
7 |
for (var j:int = 2; j < vecs_box1.length; j++) |
8 |
{
|
9 |
var curr_proj1:Number = vecs_box1[j].dotProduct(axis) |
10 |
//select the maximum projection on axis to corresponding box corners
|
11 |
if (min_proj_box1 > curr_proj1) { |
12 |
min_proj_box1 = curr_proj1 |
13 |
min_dot_box1 = j |
14 |
}
|
15 |
//select the minimum projection on axis to corresponding box corners
|
16 |
if (curr_proj1> max_proj_box1) { |
17 |
max_proj_box1 = curr_proj1 |
18 |
max_dot_box1 = j |
19 |
}
|
20 |
}
|
Finalmente, evaluamos si hay una colisión en ese eje específico, P:
1 |
var isSeparated:Boolean = max_proj_box2 < min_proj_box1 || max_proj_box1 < min_proj_box2 |
2 |
if (isSeparated) t.text = "There's a gap between both boxes" |
3 |
else t.text = "No gap calculated." |
Aquí hay una demostración de la implementación anterior:
Puede arrastrar cualquiera de los recuadros a través de su punto medio y girarlo con las teclas R y T. El punto rojo indica la esquina máxima para un cuadro, mientras que el amarillo indica el mínimo. Si un cuadro está alineado con P, es posible que estos puntos parpadeen al arrastrar, ya que esas dos esquinas comparten las mismas características.
Echa un vistazo a la fuente completa en DemoSAT2.as
en la descarga de la fuente.
Optimización
Si desea acelerar el proceso, no es necesario calcular el vector unitario de P. Por lo tanto, puede omitir una gran cantidad de costosos cálculos del teorema de Pitágoras que involucran a Math.sqrt ():
\[ \begin{bmatrix}A_x \\A_y\end{bmatrix}.
\begin{bmatrix}P_x/P_{magnitude} \\P_y/P_{magnitude}\end{bmatrix}=
A_{magnitude}*cos(theta)
\]



El razonamiento es el siguiente (consulte el diagrama anterior para obtener una guía visual sobre las variables):
1 |
/*
|
2 |
Let:
|
3 |
P_unit be the unit vector for P,
|
4 |
P_mag be P's magnitude,
|
5 |
v1_mag be v1's magnitude,
|
6 |
v2_mag be v2's magnitude,
|
7 |
theta_1 be the angle between v1 and P,
|
8 |
theta_2 be the angle between v2 and P,
|
9 |
|
10 |
Then:
|
11 |
box1.max < box2.min
|
12 |
=> v1.dotProduct(P_unit) < v2.dotProduct(P_unit)
|
13 |
=> v1_mag*cos(theta_1) < v2_mag*cos(theta_2)
|
14 |
*/
|
Ahora, matemáticamente, el signo de desigualdad sigue siendo el mismo si ambos lados de la desigualdad se multiplican por el mismo número, A:
1 |
/*
|
2 |
So:
|
3 |
A*v1_mag*cos(theta_1) < A*v2_mag*cos(theta_2)
|
4 |
|
5 |
If A is P_mag, then:
|
6 |
P_mag*v1_mag*cos(theta_1) < P_mag*v2_mag*cos(theta_2)
|
7 |
...which is equivalent to saying:
|
8 |
v1.dotProduct(P) < v2.dotProduct(P)
|
9 |
*/
|
Entonces, si es un vector unitario o no, en realidad no importa, el resultado es el mismo.
Tenga en cuenta que este enfoque es útil si solo está comprobando la superposición. Para encontrar la longitud de penetración exacta de box1
y box2
(que para la mayoría de los juegos probablemente necesites), aún necesitas calcular el vector unitario de P.
Resolviendo la segunda falla
Así que resolvimos el problema para un eje, pero ese no es el final. Todavía tenemos que abordar otros ejes, pero ¿cuáles?



El análisis de las cajas es bastante sencillo: comparamos dos ejes P y Q. Para confirmar una colisión, la superposición en todos los ejes debe ser cierta: si hay algún eje sin superposición, podemos concluir que no hay colisión.
¿Qué pasa si las cajas están orientadas de manera diferente?
Haga clic en el botón verde para pasar a otra página. Por lo tanto, en los ejes P, Q, R y S, solo hay un eje que no muestra superposición entre cuadros, y nuestra conclusión es que no hay colisión entre los cuadros.
Pero la pregunta es, ¿cómo decidimos qué ejes comprobar para la superposición? Bueno, tomamos las normales de los polígonos.



En una forma generalizada, con dos cuadros, tendremos que revisar ocho ejes: n0
, n1
, n2
y n3
para cada box1
y box2
. En una forma generalizada, con dos cuadros, tendremos que revisar ocho ejes: n0, n1, n2 y n3 para cada box1 y box2.
-
n0
yn2
debox1
-
n1
yn3
debox1
-
n0
yn2
debox2
-
n1
yn3
debox2
Así que no necesitamos pasar por los ocho; sólo cuatro lo harán. Y si box1
y box2
comparten la misma orientación, podemos reducir aún más para evaluar solo dos ejes.
¿Qué pasa con otros polígonos?



Desafortunadamente, para el triángulo y el pentágono de arriba no hay tal atajo, así que tendremos que ejecutar controles a lo largo de todas las normales.
Cálculo de las normales
Cada superficie tiene dos normales:



El diagrama de arriba muestra la normal izquierda y derecha de P. Observe los componentes conmutados del vector y el signo de cada uno.

Para mi implementación, estoy usando una convención en el sentido de las agujas del reloj, así que uso las normales de la izquierda. A continuación se muestra un extracto de SimpleSquare.as
que demuestra esto.
1 |
public function getNorm():Vector.<Vector2d> { |
2 |
var normals:Vector.<Vector2d> = new Vector.<Vector2d> |
3 |
for (var i:int = 1; i < dots.length-1; i++) |
4 |
{
|
5 |
var currentNormal:Vector2d = new Vector2d( |
6 |
dots[i + 1].x - dots[i].x, |
7 |
dots[i + 1].y - dots[i].y |
8 |
).normL //left normals |
9 |
normals.push(currentNormal); |
10 |
}
|
11 |
normals.push( |
12 |
new Vector2d( |
13 |
dots[1].x - dots[dots.length-1].x, |
14 |
dots[1].y - dots[dots.length-1].y |
15 |
).normL |
16 |
)
|
17 |
return normals; |
18 |
}
|
Nueva implementación
Estoy seguro de que puedes encontrar una manera de optimizar el siguiente código. Pero solo para que todos tengamos una idea clara de lo que está sucediendo, he escrito todo en su totalidad:
1 |
//results of P, Q
|
2 |
var result_P1:Object = getMinMax(vecs_box1, normals_box1[1]); |
3 |
var result_P2:Object = getMinMax(vecs_box2, normals_box1[1]); |
4 |
var result_Q1:Object = getMinMax(vecs_box1, normals_box1[0]); |
5 |
var result_Q2:Object = getMinMax(vecs_box2, normals_box1[0]); |
6 |
|
7 |
//results of R, S
|
8 |
var result_R1:Object = getMinMax(vecs_box1, normals_box2[1]); |
9 |
var result_R2:Object = getMinMax(vecs_box2, normals_box2[1]); |
10 |
var result_S1:Object = getMinMax(vecs_box1, normals_box2[0]); |
11 |
var result_S2:Object = getMinMax(vecs_box2, normals_box2[0]); |
12 |
|
13 |
var separate_P:Boolean = result_P1.max_proj < result_P2.min_proj || |
14 |
result_P2.max_proj < result_P1.min_proj |
15 |
var separate_Q:Boolean = result_Q1.max_proj < result_Q2.min_proj || |
16 |
result_Q2.max_proj < result_Q1.min_proj |
17 |
var separate_R:Boolean = result_R1.max_proj < result_R2.min_proj || |
18 |
result_R2.max_proj < result_R1.min_proj |
19 |
var separate_S:Boolean = result_S1.max_proj < result_S2.min_proj || |
20 |
result_S2.max_proj < result_S1.min_proj |
21 |
|
22 |
//var isSeparated:Boolean = separate_p || separate_Q || separate_R || separate_S
|
23 |
if (isSeparated) t.text = "Separated boxes" |
24 |
else t.text = "Collided boxes." |
Verás que algunas de las variables no se calculan necesariamente para alcanzar el resultado. Si cualquiera de separate_P, separate_Q, separate_R
y separate_S
es verdadero, entonces están separados y no hay necesidad de continuar.
Esto significa que podemos guardar una buena cantidad de evaluación, solo marcando cada uno de esos booleanos después de que se hayan calculado. Requeriría volver a escribir el código, pero creo que puede trabajar en él (o revisar el bloque comentado en DemoSAT3.as
).
Aquí hay una demostración de la implementación anterior. Haga clic y arrastre los cuadros a través de sus puntos intermedios, y use las teclas R y T para rotar los cuadros:
Reflexiones posteriores
Cuando se ejecuta este algoritmo, comprueba los superposiciones a través de los ejes normales. Tengo dos observaciones aquí para señalar:
- SAT es optimista de que no habrá colisión entre polígonos. El algoritmo puede salir y concluir felizmente "sin colisión" una vez que cualquier eje no muestre superposición. Si hubo una colisión, el SAT tendrá que recorrer todos los ejes para llegar a esa conclusión; por lo tanto, cuantas más colisiones haya en realidad, peor será el rendimiento del algoritmo.
- SAT utiliza la normal de cada uno de los bordes de los polígonos. Así que cuanto más complejos sean los polígonos, más caro será el SAT.
Detección de colisión hexágono-triángulo
Aquí hay otro fragmento de código de ejemplo para verificar una colisión entre un hexágono y un triángulo:
1 |
private function refresh():void { |
2 |
//prepare the normals
|
3 |
var normals_hex:Vector.<Vector2d> = hex.getNorm(); |
4 |
var normals_tri:Vector.<Vector2d> = tri.getNorm(); |
5 |
|
6 |
var vecs_hex:Vector.<Vector2d> = prepareVector(hex); |
7 |
var vecs_tri:Vector.<Vector2d> = prepareVector(tri); |
8 |
var isSeparated:Boolean = false; |
9 |
|
10 |
//use hexagon's normals to evaluate
|
11 |
for (var i:int = 0; i < normals_hex.length; i++) |
12 |
{
|
13 |
var result_box1:Object = getMinMax(vecs_hex, normals_hex[i]); |
14 |
var result_box2:Object = getMinMax(vecs_tri, normals_hex[i]); |
15 |
|
16 |
isSeparated = result_box1.max_proj < result_box2.min_proj || result_box2.max_proj < result_box1.min_proj |
17 |
if (isSeparated) break; |
18 |
}
|
19 |
//use triangle's normals to evaluate
|
20 |
if (!isSeparated) { |
21 |
for (var j:int = 1; j < normals_tri.length; j++) |
22 |
{
|
23 |
var result_P1:Object = getMinMax(vecs_hex, normals_tri[j]); |
24 |
var result_P2:Object = getMinMax(vecs_tri, normals_tri[j]); |
25 |
|
26 |
isSeparated = result_P1.max_proj < result_P2.min_proj || result_P2.max_proj < result_P1.min_proj |
27 |
if (isSeparated) break; |
28 |
}
|
29 |
}
|
30 |
|
31 |
if (isSeparated) t.text = "Separated boxes" |
32 |
else t.text = "Collided boxes." |
33 |
}
|
Para obtener el código completo, consulte DemoSAT4.as
en la descarga de origen.
La demostración está abajo. La interacción es la misma que en las demostraciones anteriores: arrastre a través de los puntos centrales y use R y T para rotar.
Detección de colisión de caja-círculo



La colisión con un círculo puede ser una de las más simples. Debido a que su proyección es la misma en todas las direcciones (es simplemente el radio del círculo), podemos hacer lo siguiente:
1 |
private function refresh():void { |
2 |
//prepare the vectors
|
3 |
var v:Vector2d; |
4 |
var current_box_corner:Point; |
5 |
var center_box:Point = box1.getDot(0); |
6 |
|
7 |
var max:Number = Number.NEGATIVE_INFINITY; |
8 |
var box2circle:Vector2d = new Vector2d(c.x - center_box.x, c.y - center_box.y) |
9 |
var box2circle_normalised:Vector2d = box2circle.unitVector |
10 |
|
11 |
//get the maximum
|
12 |
for (var i:int = 1; i < 5; i++) |
13 |
{
|
14 |
current_box_corner = box1.getDot(i) |
15 |
v = new Vector2d( |
16 |
current_box_corner.x - center_box.x , |
17 |
current_box_corner.y - center_box.y); |
18 |
var current_proj:Number = v.dotProduct(box2circle_normalised) |
19 |
|
20 |
if (max < current_proj) max = current_proj; |
21 |
}
|
22 |
if (box2circle.magnitude - max - c.radius > 0 && box2circle.magnitude > 0) t.text = "No Collision" |
23 |
else t.text = "Collision" |
24 |
}
|
Echa un vistazo a la fuente completa en DemoSAT5.as
. Arrastra el círculo o la caja para verlos chocar.
Conclusión
Bueno eso es todo por ahora. Gracias por leer y deje sus comentarios con un comentario o pregunta. ¡Nos vemos en el siguiente tutorial!