Cómo crear un motor de física 2D personalizado el motor central

En esta parte de mi serie sobre la creación de un motor de física 2D personalizado para sus juegos, agregaremos más funciones a la resolución de impulsos que obtuvimos trabajando en la primera parte. En particular, veremos la integración, el paso del tiempo, el uso de un diseño modular para nuestro código y la detección de colisión de fase amplia.


Introducción

En el último post de esta serie traté el tema de la resolución de impulsos. Lee eso primero, si no lo has hecho ya!

Vamos a sumergirnos directamente en los temas tratados en este artículo. Estos temas son todas las necesidades de cualquier motor de física medio decente, por lo que ahora es el momento adecuado para crear más funciones sobre la resolución principal del último artículo..

  • Integración
  • Paso del tiempo
  • Diseño modular
    • Cuerpos
    • Formas
    • Efectivo
    • Materiales
  • Fase amplia
    • Contacto par duplicado de selección
    • Capas
  • Prueba de intersección del espacio medio

Integración

La integración es completamente sencilla de implementar, y hay muchas áreas en Internet que proporcionan buena información para la integración iterativa. Esta sección mostrará principalmente cómo implementar una función de integración adecuada y señalará algunas ubicaciones diferentes para una lectura adicional, si lo desea..

Primero se debe saber qué es realmente la aceleración. La segunda ley de Newton dice:

\ [Ecuación 1: \\
F = ma \]

Esto establece que la suma de todas las fuerzas que actúan sobre un objeto es igual a la masa de ese objeto. metro multiplicado por su aceleración una. metro está en kilogramos, una está en metros / segundo, y F esta en Newtons.

Reorganizando esta ecuación un poco para resolver una rendimientos

\ [Ecuación 2: \\
a = \ frac F m \\
\por lo tanto\\
a = F * \ frac 1 m \]

El siguiente paso consiste en utilizar la aceleración para pasar un objeto de un lugar a otro. Dado que un juego se muestra en cuadros separados discretos en una animación similar a una ilusión, las ubicaciones de cada posición en estos pasos discretos deben calcularse. Para una cobertura más profunda de estas ecuaciones, consulte: Demostración de integración de Erin Catto del GDC 2009 y la adición de Hannu al Epler simpléctico para una mayor estabilidad en entornos con bajo FPS.

La integración explícita de Euler (que se pronuncia "engrasador") se muestra en el siguiente fragmento de código, donde X es posición y v es la velocidad Tenga en cuenta que 1 / m * F es la aceleración, como se explicó anteriormente:

 // Explicit Euler x + = v * dt v + = (1 / m * F) * dt
dt Aquí se refiere al tiempo delta. Δ es el símbolo para delta, y se puede leer literalmente como "cambiar en", o se puede escribir comot. Así que cada vez que veas dt Se puede leer como "cambio en el tiempo". dv sería "cambio de velocidad".

Esto funcionará, y se usa comúnmente como punto de partida. Sin embargo, tiene imprecisiones numéricas que podemos eliminar sin ningún esfuerzo adicional. Esto es lo que se conoce como Euler simpléctico:

 // Epler simplplico v + = (1 / m * F) * dt x + = v * dt

Tenga en cuenta que todo lo que hice fue reorganizar el orden de las dos líneas de código - vea "> el artículo mencionado anteriormente de Hannu.

Esta publicación explica las inexactitudes numéricas de Explicit Euler, pero se le advierte que comienza a cubrir RK4, que personalmente no recomiendo: gafferongames.com: Euler Inexcuracy.

Estas simples ecuaciones son todo lo que necesitamos para mover todos los objetos con velocidad y aceleración lineal.


Paso del tiempo

Dado que los juegos se muestran en intervalos de tiempo discretos, debe haber una forma de manipular el tiempo entre estos pasos de manera controlada. ¿Alguna vez has visto un juego que se ejecutará a diferentes velocidades dependiendo de la computadora en la que se está jugando? Este es un ejemplo de un juego que se ejecuta a una velocidad que depende de la capacidad de la computadora para ejecutar el juego..

Necesitamos una forma de asegurarnos de que nuestro motor de física solo funcione cuando haya transcurrido una cantidad específica de tiempo. De esta manera, el dt que se utiliza en los cálculos es siempre el mismo número exacto. Usando el mismo dt el valor en su código en todas partes realmente hará que su motor de física determinista, y es conocido como paso de tiempo fijo. Ésto es una cosa buena.

Un motor de física determinista es aquel que siempre hará exactamente lo mismo cada vez que se ejecute, siempre que se den las mismas entradas. Esto es esencial para muchos tipos de juegos en los que el juego debe estar muy bien adaptado al comportamiento del motor de física. Esto también es esencial para depurar su motor de física, ya que para detectar errores, el comportamiento de su motor debe ser consistente.

Primero vamos a cubrir una versión simple de un paso de tiempo fijo. Aquí hay un ejemplo:

 const float fps = 100 const float dt = 1 / fps float acumulador = 0 // En unidades de segundos float frameStart = GetCurrentTime () // main loop while (true) const float currentTime = GetCurrentTime () // Almacenar el tiempo transcurrido desde el último fotograma comenzó el acumulador + = currentTime - frameStart () // Registre el inicio de este frame frameStart = currentTime while (acumulator> dt) UpdatePhysics (dt) acumulador - = dt RenderGame ()

Esto espera alrededor, renderizando el juego, hasta que haya transcurrido el tiempo suficiente para actualizar la física. El tiempo transcurrido es registrado, y discreto. dt-Se extraen trozos de tiempo del acumulador y se procesan por la física. Esto garantiza que se pase el mismo valor exacto a la física sin importar qué, y que el valor pasado a la física es una representación precisa del tiempo real que pasa en la vida real. Trozos de dt se eliminan de la acumulador hasta el acumulador es más pequeño que un dt pedazo.

Hay un par de problemas que pueden ser resueltos aquí. El primero implica cuánto tiempo lleva realizar la actualización de física: ¿Qué sucede si la actualización de física lleva demasiado tiempo y la acumulador va más y más alto cada bucle de juego? Esto se llama la espiral de la muerte. Si esto no se soluciona, su motor se detendrá rápidamente si su física no se puede realizar lo suficientemente rápido.

Para resolver esto, el motor realmente necesita ejecutar menos actualizaciones físicas si el acumulador se pone muy alto Una forma simple de hacer esto sería sujetar la acumulador debajo de algún valor arbitrario.

 const float fps = 100 const float dt = 1 / fps float acumulador = 0 // En unidades segundos float frameStart = GetCurrentTime () // ciclo principal while (true) const float currentTime = GetCurrentTime () // Almacena el tiempo transcurrido desde que el último fotograma comenzó el acumulador + = currentTime - frameStart () // Registre el inicio de este frame frameStart = currentTime // Evite la espiral de la muerte y la pinza dt, y así apriete // cuántas veces se puede llamar a UpdatePhysics en // un solo juego lazo. if (acumulador> 0.2f) acumulador = 0.2f mientras que (acumulador> dt) UpdatePhysics (dt) acumulador - = dt RenderGame ()

Ahora, si un juego que ejecuta este bucle alguna vez encuentra algún tipo de estancamiento por cualquier razón, la física no se ahogará en una espiral de muerte. El juego simplemente se ejecutará un poco más lento, según corresponda..

Lo siguiente que hay que arreglar es bastante menor en comparación con la espiral de la muerte. Este bucle esta tomando dt trozos de la acumulador hasta el acumulador es más pequeño que dt. Esto es divertido, pero aún queda un poco de tiempo restante en el acumulador. Esto plantea un problema.

Asumir el acumulador se queda con 1/5 de un dt fragmentar cada cuadro En el sexto marco el acumulador Tendrá suficiente tiempo restante para realizar una actualización de física más que todos los otros marcos. Esto dará como resultado un fotograma cada segundo o, por lo tanto, realizará un salto discreto ligeramente mayor en el tiempo y podría ser muy notable en tu juego.

Para resolver esto, el uso de Interpolación linear es requerido. Si esto suena aterrador, no te preocupes, se mostrará la implementación. Si desea comprender la implementación, hay muchos recursos en línea para la interpolación lineal..

 // interpolación lineal para a de 0 a 1 // de t1 a t2 t1 * a + t2 (1.0f - a)

Usando esto podemos interpolar (aproximadamente) donde podríamos estar entre dos intervalos de tiempo diferentes. Esto se puede usar para representar el estado de un juego entre dos actualizaciones de física diferentes.

Con la interpolación lineal, la representación de un motor puede ejecutarse a un ritmo diferente al del motor de física. Esto permite un manejo agraciado de las sobras. acumulador de las actualizaciones de física.

Aquí hay un ejemplo completo:

 const float fps = 100 const float dt = 1 / fps float acumulador = 0 // En unidades segundos float frameStart = GetCurrentTime () // ciclo principal while (true) const float currentTime = GetCurrentTime () // Almacena el tiempo transcurrido desde que el último fotograma comenzó el acumulador + = currentTime - frameStart () // Registre el inicio de este frame frameStart = currentTime // Evite la espiral de la muerte y la pinza dt, y así apriete // cuántas veces se puede llamar a UpdatePhysics en // un solo juego lazo. if (acumulador> 0.2f) acumulador = 0.2f mientras que (acumulador> dt) UpdatePhysics (dt) acumulador - = dt const float alpha = acumulador / dt; RenderGame (alpha) void RenderGame (float alpha) para shape in game do // calcula una transformada interpolada para renderizar Transform i = shape.previous * alpha + shape.current * (1.0f - alpha) shape.previous = shape.current shape .Render (i)

Aquí, todos los objetos dentro del juego se pueden dibujar en momentos variables entre tiempos de física discretos. Esto manejará con gracia todo el error y el tiempo restante acumulado. Esto en realidad se está mostrando ligeramente por detrás de lo que la física ha resuelto actualmente, pero al ver el juego correr, todo el movimiento se suaviza perfectamente mediante la interpolación..

El jugador nunca sabrá que el renderizado está ligeramente por detrás de la física, ya que el jugador solo sabrá lo que ve, y lo que verá es una transición perfectamente suave de un cuadro a otro.

Quizás se esté preguntando: "¿por qué no interpolamos de la posición actual a la siguiente?". Intenté esto y se requiere la representación para "adivinar" dónde estarán los objetos en el futuro. A menudo, los objetos en un motor de física realizan cambios repentinos en el movimiento, como durante una colisión, y cuando se produce un cambio tan repentino, los objetos se teletransportan debido a interpolaciones inexactas en el futuro..


Diseño modular

Hay algunas cosas que todo objeto de física va a necesitar. Sin embargo, las cosas específicas que necesita cada objeto de física pueden cambiar ligeramente de un objeto a otro. Se requiere una forma inteligente de organizar todos estos datos, y se podría suponer que se desea la menor cantidad de código que se debe escribir para lograr dicha organización. En este caso, algún diseño modular sería de buen uso..

El diseño modular probablemente suene un poco pretencioso o demasiado complicado, pero tiene sentido y es bastante simple. En este contexto, "diseño modular" simplemente significa que queremos dividir un objeto físico en partes separadas, para que podamos conectarlo o desconectarlo como lo creamos..

Cuerpos

Un cuerpo de física es un objeto que contiene toda la información sobre un objeto de física determinado. Almacena la (s) forma (s) que representa el objeto, datos de masa, transformación (posición, rotación), velocidad, par, etc. Aquí está lo que nuestro cuerpo debe parecerse a

 struct body Shape * shape; Transformar tx; Material material MassData mass_data; Vec2 velocidad; Fuerza vec2; gravityScale real; ;

Este es un gran punto de partida para el diseño de una estructura corporal física. Hay algunas decisiones inteligentes que se toman aquí y que tienden hacia una organización de código fuerte..

Lo primero que hay que notar es que una forma está contenida dentro del cuerpo por medio de un puntero. Esto representa una relación suelta entre el cuerpo y su forma. Un cuerpo puede contener cualquier forma, y ​​la forma de un cuerpo puede ser intercambiada a voluntad. De hecho, un cuerpo puede ser representado por múltiples formas, y tal cuerpo sería conocido como un "compuesto", ya que estaría compuesto de múltiples formas. (No voy a cubrir los composites en este tutorial).

Interfaz cuerpo y forma.

los forma es responsable de computar las formas delimitadas, calcular la masa en función de la densidad y renderizar.

los mass_data Es una pequeña estructura de datos que contiene información relacionada con la masa:

 struct MassData flotar masa; float inv_mass; // Para rotaciones (no cubiertas en este artículo) inercia flotante; float inverse_inertia; ;

Es bueno almacenar todos los valores relacionados con la masa y la intertia en una sola estructura. La masa nunca debe fijarse a mano, la masa siempre debe calcularse por la forma misma. La masa es un tipo de valor bastante poco intuitivo, y configurarlo a mano llevará mucho tiempo de ajustes. Se define como:

\ [Ecuación 3: \\ masa = densidad * volumen \]

Cuando un diseñador quiere que una forma sea más "masiva" o "pesada", debe modificar la densidad de una forma. Esta densidad se puede utilizar para calcular la masa de una forma dado su volumen. Esta es la forma correcta de abordar la situación, ya que la densidad no se ve afectada por el volumen y nunca cambiará durante el tiempo de ejecución del juego (a menos que se admita específicamente con un código especial).

Algunos ejemplos de formas como AABBs y Círculos se pueden encontrar en el tutorial anterior de esta serie..

Materiales

Toda esta conversación sobre masa y densidad lleva a la pregunta: ¿Dónde está el valor de densidad? Reside dentro del Material estructura:

 estructura material densidad del flotador; restitución de flotadores ;

Una vez que se establecen los valores del material, este material se puede pasar a la forma de un cuerpo para que el cuerpo pueda calcular la masa..

Lo último que vale la pena mencionar es el gravity_scale. La escala de la gravedad para diferentes objetos es tan a menudo necesaria para ajustar el juego que es mejor incluir un valor en cada cuerpo específicamente para esta tarea..

Se pueden utilizar algunos ajustes de material útiles para tipos de materiales comunes para construir un Material objeto de un valor de enumeración:

 Densidad de la roca: 0.6 Restitución: 0.1 Densidad de la madera: 0.3 Restitución: 0.2 Densidad del metal: 1.2 Restitución: 0.05 Densidad BouncyBall: 0.3 Restitución: 0.8 Densidad SuperBall: 0.3 Restitución: 0.95 Densidad de almohada: 0.1 Restitución: 0.2 Densidad estática: 0.0 Restitución: 0.4

Efectivo

Hay una cosa más de que hablar en el cuerpo estructura. Hay un miembro de datos llamado fuerza. Este valor comienza en cero al comienzo de cada actualización física. Se añadirán otras influencias en el motor de física (como la gravedad). Vec2 vectores en este fuerza miembro de datos Justo antes de la integración, toda esta fuerza se usará para calcular la aceleración del cuerpo y se usará durante la integración. Después de la integración este fuerza miembro de datos se pone a cero.

Esto permite que cualquier número de fuerzas actúen sobre un objeto siempre que lo consideren adecuado, y no se requerirá que se escriba ningún código adicional cuando se apliquen nuevos tipos de fuerzas a los objetos..

Tomemos un ejemplo. Digamos que tenemos un pequeño círculo que representa un objeto muy pesado. Este pequeño círculo está volando en el juego, y es tan pesado que tira otros objetos hacia él muy ligeramente. Aquí hay un pseudocódigo aproximado para demostrar esto:

 El objeto HeavyObject para el cuerpo en el juego do if (object.CloseEnoughTo (body) object.ApplyForcePullOn (body)

La función ApplyForcePullOn () Tal vez podría aplicar una pequeña fuerza para tirar de la cuerpo hacia el HeavyObject, solo si el cuerpo está lo suficientemente cerca.


Dos objetos tirados hacia uno más grande moviéndolos pegados. Las fuerzas de tracción dependen de su distancia a la caja más grande.

No importa cuántas fuerzas se agreguen a la fuerza de un cuerpo, ya que todos se sumarán a un solo vector de fuerza sumada para ese cuerpo. Esto significa que dos fuerzas que actúan sobre el mismo cuerpo pueden cancelarse mutuamente.


Fase amplia

En el artículo anterior de esta serie se introdujeron rutinas de detección de colisiones. Estas rutinas fueron en realidad separadas de lo que se conoce como la "fase estrecha". Las diferencias entre fase amplia y fase estrecha se pueden investigar con bastante facilidad con una búsqueda de Google.

(En resumen: utilizamos la detección de colisión de fase amplia para descubrir qué pares de objetos podría colisión, y luego detección de colisión de fase estrecha para comprobar si realmente son chocando.)

Me gustaría proporcionar un código de ejemplo junto con una explicación de cómo implementar una fase amplia de \ (O (n ^ 2) \) cálculos de par de complejidad de tiempo.

\ (O (n ^ 2) \) significa esencialmente que el tiempo necesario para verificar cada par de posibles colisiones dependerá del cuadrado del número de objetos. Utiliza notación Big-O..

Ya que estamos trabajando con pares de objetos, será útil crear una estructura como esta:

 struct Pair body * A; cuerpo * B; ;

Una fase amplia debería recopilar un montón de posibles colisiones y almacenarlas todas en Par estructuras Estos pares se pueden pasar a otra parte del motor (la fase estrecha) y luego se pueden resolver..

Ejemplo de fase amplia:

 // Genera la lista de pares. // Todos los pares anteriores se borran cuando se llama a esta función. void BroadPhase :: GeneratePairs (void) pairs.clear () // Espacio de caché para AABBs que se utilizará en el cálculo // del cuadro delimitador de cada forma AABB A_aabb AABB B_aabb para (i = bodies.begin (); i! = bodies .end (); i = i-> next) for (j = bodies.begin (); j! = bodies.end (); j = j-> next) Body * A = & i-> GetData () Cuerpo * B = & j-> GetData () // Saltar la comprobación con sí mismo si (A == B) continuar A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) if (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back (A, B)

El código anterior es bastante simple: compruebe cada cuerpo contra cada cuerpo y omita las autocomprobaciones.

Cachear duplicados

Hay un problema en la última sección: ¡se devolverán muchos pares duplicados! Estos duplicados deben ser eliminados de los resultados. Se requerirá cierta familiaridad con los algoritmos de clasificación aquí si no tiene algún tipo de biblioteca de clasificación disponible. Si estás usando C ++, entonces estás de suerte:

 // Ordenar pares para exponer duplicados ordenar (pares, pairs.end (), SortPairs); // Colectores de cola para resolver int i = 0; mientras yo < pairs.size( ))  Pair *pair = pairs.begin( ) + i; uniquePairs.push_front( pair ); ++i; // Skip duplicate pairs by iterating i until we find a unique pair while(i < pairs.size( ))  Pair *potential_dup = pairs + i; if(pair->A! = Potencial_dup-> B || par-> B! = potencial_dup-> A) ruptura; ++ i; 

Después de ordenar todos los pares en un orden específico, se puede suponer que todos los pares en el pares El contenedor tendrá todos los duplicados adyacentes entre sí. Coloque todos los pares únicos en un nuevo contenedor llamado pares únicos, y el trabajo de selección de duplicados está terminado.

Lo último a mencionar es el predicado. SortPairs (). Esta SortPairs () la función es lo que realmente se usa para hacer la clasificación, y podría verse así:

 bool SortPairs (Pair lhs, Pair rhs) if (lhs.A < rhs.A) return true; if(lhs.A == rhs.A) return lhs.B < rhs.B; return false; 
Los términos lhs y rs Puede leerse como "lado izquierdo" y "lado derecho". Estos términos se usan comúnmente para referirse a parámetros de funciones donde las cosas se pueden ver lógicamente como el lado izquierdo y derecho de alguna ecuación o algoritmo.

Capas

Capas se refiere al hecho de tener objetos diferentes que nunca chocan entre sí. Esta es la clave para que las balas disparadas desde ciertos objetos no afecten a otros objetos. Por ejemplo, los jugadores de un equipo pueden querer que sus cohetes dañen a los enemigos pero no a los demás..


Representación de las capas; algunos objetos chocan entre sí, otros no.

La capa se implementa mejor con máscaras de bits - vea una Guía rápida para los programadores y la página de Wikipedia para una introducción rápida, y la sección de Filtrado del manual de Box2D para ver cómo ese motor usa máscaras de bits.

Las capas se deben hacer dentro de la fase amplia. Aquí solo pegaré un ejemplo de fase amplia terminada:

 // Genera la lista de pares. // Todos los pares anteriores se borran cuando se llama a esta función. void BroadPhase :: GeneratePairs (void) pairs.clear () // Espacio de caché para AABBs que se utilizará en el cálculo // del cuadro delimitador de cada forma AABB A_aabb AABB B_aabb para (i = bodies.begin (); i! = bodies .end (); i = i-> next) for (j = bodies.begin (); j! = bodies.end (); j = j-> next) Body * A = & i-> GetData () Cuerpo * B = & j-> GetData () // Saltar la comprobación con sí mismo si (A == B) continuar // Solo se considerarán las capas coincidentes si (! (A-> capas & B-> capas)) continúe; A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) if (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back (A, B)

Las capas resultan ser altamente eficientes y muy simples..


Intersección del espacio medio

UNA medio espacio Se puede ver como un lado de una línea en 2D. Detectar si un punto está en un lado de la línea o en el otro es una tarea bastante común, y cualquiera que cree su propio motor de física debe entenderlo bien. Es una lástima que este tema no esté realmente cubierto en ningún lugar de Internet de manera significativa, al menos por lo que he visto, hasta ahora, por supuesto.!

La ecuación general de una línea en 2D es:

\ [Ecuación 4: \\
General \: forma: ax + by + c = 0 \\
Normal \: to \: line: \ begin bmatrix
una \\
b \\
\ end bmatrix \]

Tenga en cuenta que, a pesar de su nombre, el vector normal no está necesariamente normalizado (es decir, no necesariamente tiene una longitud de 1).

Para ver si un punto está en un lado particular de esta línea, todo lo que necesitamos hacer es conectar el punto en el X y y Variables en la ecuación y verifica el signo del resultado. Un resultado de 0 significa que el punto está en la línea, y positivo / negativo significa diferentes lados de la línea.

Eso es todo lo que hay que hacer! Sabiendo esto, la distancia desde un punto a la línea es en realidad el resultado de la prueba anterior. Si el vector normal no está normalizado, el resultado se escalará por la magnitud del vector normal.


Conclusión

Por ahora, un motor de física completo, aunque simple, puede construirse completamente desde cero. Los temas más avanzados, como la fricción, la orientación y el árbol dinámico AABB, se pueden cubrir en futuros tutoriales. Por favor haga preguntas o proporcione comentarios a continuación, disfruto leerlos y responderlos.!