Dans cette partie de ma série consacrée à la création d’un moteur physique 2D personnalisé pour vos jeux, nous ajouterons davantage de fonctionnalités à la résolution d’impulsions obtenue dans la première partie. En particulier, nous examinerons l’intégration, l’horodatage, l’utilisation d’une conception modulaire pour notre code et la détection de collision de phase large.
Dans le dernier article de cette série, j'ai abordé le sujet de la résolution d'impulsion. Lisez-le d'abord, si vous ne l'avez pas déjà fait!
Passons directement aux sujets abordés dans cet article. Ces sujets sont l’une des nécessités de tout moteur physique à la moitié décent. C’est donc le moment opportun de développer davantage de fonctionnalités au-dessus de la résolution de base du dernier article..
L'intégration est tout à fait simple à mettre en œuvre et de très nombreux domaines sur Internet fournissent des informations utiles pour l'intégration itérative. Cette section montrera principalement comment mettre en œuvre une fonction d'intégration appropriée et pointer vers différents emplacements pour une lecture ultérieure, si nécessaire..
Tout d'abord, il faut savoir ce qu'est l'accélération. La deuxième loi de Newton dit:
\ [Équation 1: \\
F = ma \]
Cela indique que la somme de toutes les forces agissant sur un objet est égale à la masse de cet objet. m
multiplié par son accélération une
. m
est en kilogrammes, une
est en mètres / seconde, et F
est en newtons.
Réarranger cette équation un peu à résoudre pour une
rendements:
\ [Équation 2: \\
a = \ frac F m \\
\donc\\
a = F * \ frac 1 m \]
L'étape suivante consiste à utiliser l'accélération pour déplacer un objet d'un emplacement à un autre. Dans la mesure où un jeu est affiché dans des images distinctes et distinctes dans une animation de type illusion, les emplacements de chaque position à ces étapes distinctes doivent être calculés. Pour une couverture plus détaillée de ces équations, veuillez consulter: la démo sur l'intégration d'Erin Catto à partir de GDC 2009 et l'addition de Hannu à Euler symplectique pour plus de stabilité dans les environnements à faible FPS.
L’intégration explicite d’Euler (prononcée par «graisseur») est présentée dans l’extrait suivant, où X
est la position et v
est la vitesse. S'il vous plaît noter que 1 / m * F
est l'accélération, comme expliqué ci-dessus:
// Euler explicite x + = v * dt v + = (1 / m * F) * dt
dt
ici se réfère au temps delta. Δ est le symbole pour delta, et peut être lu littéralement comme "changer" ou écrit comme Δt
. Donc chaque fois que vous voyez dt
on peut le lire comme "changement dans le temps". dv
serait "changement de vitesse". Cela fonctionnera et est couramment utilisé comme point de départ. Cependant, il comporte des imprécisions numériques dont nous pouvons nous débarrasser sans effort supplémentaire. Voici ce qu'on appelle Symplectic Euler:
// Euler symplectique v + = (1 / m * F) * dt x + = v * dt
Notez que tout ce que je fis fut de réarranger l’ordre des deux lignes de code - voir "> l’article précité de Hannu.
Cet article explique les inexactitudes numériques de Euler explicite, mais sachez qu'il commence à couvrir RK4, ce que je ne recommande pas personnellement: gafferongames.com: Euler Inaccuracy.
Ces équations simples sont tout ce dont nous avons besoin pour déplacer tous les objets avec une vitesse et une accélération linéaires.
Étant donné que les jeux sont affichés à des intervalles de temps distincts, il doit exister un moyen de manipuler le temps entre ces étapes de manière contrôlée. Avez-vous déjà vu un jeu fonctionner à différentes vitesses en fonction de l'ordinateur sur lequel il est utilisé? C'est un exemple de jeu fonctionnant à une vitesse dépendant de la capacité de l'ordinateur à exécuter le jeu..
Nous avons besoin d'un moyen de nous assurer que notre moteur physique ne fonctionne que lorsqu'un certain laps de temps s'est écoulé. De cette façon, le dt
qui est utilisé dans les calculs est toujours exactement le même nombre. En utilisant exactement le même dt
la valeur de votre code partout fera réellement votre moteur physique déterministe, et est connu comme pas de temps fixe. C'est une bonne chose.
Un moteur de physique déterministe est un moteur qui fera toujours exactement la même chose chaque fois qu'il est exécuté en supposant que les mêmes entrées sont données. Ceci est essentiel pour de nombreux types de jeux où le jeu doit être très bien adapté au comportement du moteur physique. Ceci est également essentiel pour le débogage de votre moteur physique, car pour identifier les bogues, le comportement de votre moteur doit être cohérent..
Parlons d'abord d'une version simple d'un pas de temps fixe. Voici un exemple:
const float fps = 100 const float dt = 1 / fps float accumulator = 0 // en unités de secondes float frameStart = GetCurrentTime () // boucle principale while (true) const float currentTime = GetCurrentTime () // enregistre le temps écoulé depuis la dernière image a commencé accumulator + = currentTime - frameStart () // Enregistre le début de cette image frameStart = currentTime while (accumulator> dt) UpdatePhysics (dt) accumulator - = dt RenderGame ()
Cela attend, rendant le jeu, jusqu'à ce que suffisamment de temps se soit écoulé pour mettre à jour la physique. Le temps écoulé est enregistré et discret dt
-des morceaux de temps dimensionnés sont extraits de l'accumulateur et traités par la physique. Cela garantit que la physique a toujours exactement la même valeur, et que la valeur transmise à la physique est une représentation précise du temps réel qui passe dans la vie réelle. Morceaux de dt
sont retirés de la accumulateur
jusqu'à ce que le accumulateur
est plus petit qu'un dt
tronçon.
Quelques problèmes peuvent être résolus ici. La première concerne le temps nécessaire à la réalisation de la mise à jour physique: que se passera-t-il si la mise à jour physique prend trop de temps et si accumulateur
va de plus en plus haut chaque boucle de jeu? Cela s'appelle la spirale de la mort. Si cela n'est pas corrigé, votre moteur s'arrêtera rapidement si votre physique ne peut pas être effectuée assez rapidement..
Pour résoudre ce problème, le moteur doit simplement exécuter moins de mises à jour de la physique si la accumulateur
devient trop élevé. Un moyen simple de le faire serait de bloquer le accumulateur
en dessous d'une valeur arbitraire.
const float fps = 100 const float dt = 1 / fps float accumulator = 0 // en unités secondes float frameStart = GetCurrentTime () // boucle principale while (true) const float currentTime = GetCurrentTime () // stocke le temps écoulé depuis le accum frame + = currentTime - frameStart () // Enregistrez le début de ce cadre frameStart = currentTime // Évitez la spirale de la mort et bloquez dt, bloquant ainsi // le nombre de fois que UpdatePhysics peut être appelé // dans un seul jeu boucle. if (accumulateur> 0.2f) accumulateur = 0.2f tant que (accumulateur> dt) UpdatePhysics (dt) accumulateur - = dt RenderGame ()
Maintenant, si un jeu qui exécute cette boucle rencontre un blocage, quelle qu'en soit la raison, la physique ne se noiera pas dans une spirale de mort. Le jeu fonctionnera simplement un peu plus lentement, le cas échéant.
La prochaine chose à corriger est assez mineure en comparaison de la spirale de la mort. Cette boucle prend dt
morceaux de la accumulateur
jusqu'à ce que le accumulateur
est plus petit que dt
. C'est amusant, mais il reste encore un peu de temps dans le accumulateur
. Cela pose un problème.
Assumer la accumulateur
est laissé avec 1 / 5ème d'un dt
Morceau chaque image. Au sixième cadre, le accumulateur
aura assez de temps pour effectuer une mise à jour physique de plus que toutes les autres trames. Cela se traduira par une image toutes les secondes ou plus, effectuant un saut légèrement plus grand dans le temps, et pourrait être très perceptible dans votre jeu..
Pour résoudre ce problème, l'utilisation de interpolation linéaire est requis. Si cela semble effrayant, ne vous inquiétez pas - la mise en œuvre sera affichée. Si vous voulez comprendre la mise en œuvre, il existe de nombreuses ressources en ligne pour l’interpolation linéaire..
// interpolation linéaire pour a de 0 à 1 // de t1 à t2 t1 * a + t2 (1.0f - a)
En utilisant cela, nous pouvons interpoler (approximativement) où nous pourrions être entre deux intervalles de temps différents. Cela peut être utilisé pour rendre l'état d'un jeu entre deux mises à jour physiques différentes.
Avec l'interpolation linéaire, le rendu d'un moteur peut s'exécuter à un rythme différent de celui du moteur physique. Cela permet une manipulation élégante des restes accumulateur
des mises à jour de la physique.
Voici un exemple complet:
const float fps = 100 const float dt = 1 / fps float accumulator = 0 // en unités secondes float frameStart = GetCurrentTime () // boucle principale while (true) const float currentTime = GetCurrentTime () // stocke le temps écoulé depuis le accum frame + = currentTime - frameStart () // Enregistrez le début de ce cadre frameStart = currentTime // Évitez la spirale de la mort et bloquez dt, bloquant ainsi // le nombre de fois que UpdatePhysics peut être appelé // dans un seul jeu boucle. if (accumulateur> 0.2f) accumulateur = 0.2f while (accumulateur> dt) UpdatePhysics (dt) accumulateur - = dt constant float alpha = accumulateur / dt; RenderGame (alpha) void RenderGame (float alpha) pour une forme dans le jeu doit // calculer une transformation interpolée pour le rendu. Transform i = shape.previous * alpha + shape.current * (1.0f - alpha) shape.previous = shape.current .Render (i)
Ici, tous les objets du jeu peuvent être dessinés à des moments variables entre des pas de temps physiques distincts. Ceci gérera gracieusement toutes les accumulations d’erreur et de temps restant. C’est en fait un léger retard par rapport à ce que la physique a résolu à présent, mais lorsque vous regardez le match tourner, tous les mouvements sont lissés à la perfection par l’interpolation..
Le joueur ne saura jamais que le rendu est légèrement en retrait par rapport à la physique, car il ne saura que ce qu'il voit et ce qu'il verra, ce sont des transitions parfaitement douces d'une image à l'autre..
Vous vous demandez peut-être "pourquoi ne pas interpoler de la position actuelle à la suivante?". J'ai essayé cela et cela nécessite le rendu pour "deviner" où les objets seront dans le futur. Souvent, les objets d'un moteur physique modifient brusquement les mouvements, par exemple lors d'une collision, et lorsqu'un tel changement soudain se produit, les objets se téléportent en raison d'interpolations imprécises dans le futur..
Il y a quelques choses dont chaque objet physique aura besoin. Cependant, les éléments spécifiques dont chaque objet physique a besoin peuvent légèrement varier d’un objet à l’autre. Un moyen astucieux d’organiser toutes ces données est nécessaire, et il serait supposé que la moindre quantité de code à écrire pour réaliser une telle organisation est souhaitée. Dans ce cas, une conception modulaire serait utile.
La conception modulaire semble probablement un peu prétentieuse ou trop compliquée, mais elle a du sens et est assez simple. Dans ce contexte, "conception modulaire" signifie simplement que nous voulons diviser un objet de physique en plusieurs parties distinctes, de manière à pouvoir les connecter ou les déconnecter comme bon nous semble..
Un corps physique est un objet qui contient toutes les informations sur un objet physique donné. Il stockera la ou les formes que l'objet est représenté par: données de masse, transformation (position, rotation), vitesse, couple, etc. Voici ce que notre corps
devrait ressembler à:
struct body Shape * shape; Transformer tx; Matériau matériel; MassData mass_data; Vec2 vélocité; Vec2 force; gravityScale réel; ;
C'est un excellent point de départ pour la conception d'une structure de corps physique. Certaines décisions intelligentes prises ici tendent à une forte organisation du code.
La première chose à noter est qu'une forme est contenue dans le corps au moyen d'un pointeur. Cela représente une relation lâche entre le corps et sa forme. Un corps peut contenir n'importe quelle forme et la forme d'un corps peut être intervertie à volonté. En fait, un corps peut être représenté par plusieurs formes et un tel corps serait appelé "composite", car il serait composé de plusieurs formes. (Je ne vais pas couvrir les composites dans ce tutoriel.)
Interface corps et forme.le forme
lui-même est responsable du calcul des formes de contour, du calcul de la masse en fonction de la densité et du rendu.
le masse_data
est une petite structure de données destinée à contenir des informations relatives à la masse:
struct MassData float mass; float inv_mass; // Pour les rotations (non couvertes dans cet article) float inertia; float inverse_inertia; ;
Il est agréable de stocker toutes les valeurs liées à la masse et à l’intertia dans une seule structure. La masse ne doit jamais être réglée à la main - la masse doit toujours être calculée par la forme elle-même. La masse est un type de valeur peu intuitif, et la régler à la main prendra beaucoup de temps. Il est défini comme:
\ [Équation 3: \\ masse = densité * volume \]
Lorsqu'un concepteur souhaite qu'une forme soit plus "massive" ou "lourde", il doit modifier la densité d'une forme. Cette densité peut être utilisée pour calculer la masse d’une forme en fonction de son volume. C’est la bonne façon de faire face à la situation, car la densité n’est pas affectée par le volume et ne changera jamais pendant le jeu (sauf si spécifiquement pris en charge avec un code spécial).
Quelques exemples de formes telles que les AABB et les cercles peuvent être trouvés dans le tutoriel précédent de cette série.
Toutes ces discussions sur la masse et la densité conduisent à la question suivante: Où se situe la valeur de la densité? Il réside dans le Matériel
structure:
struct Material densité de flottement; restitution flottante; ;
Une fois que les valeurs du matériau sont définies, ce matériau peut être transformé en corps afin que le corps puisse calculer la masse..
La dernière chose digne de mention est le gravity_scale
. La mise à l'échelle de la gravité pour différents objets est si souvent nécessaire pour peaufiner le jeu qu'il est préférable d'inclure une valeur dans chaque corps spécifiquement pour cette tâche..
Certains paramètres de matériau utiles pour les types de matériau courants peuvent être utilisés pour construire un Matériel
objet d'une valeur d'énumération:
Densité du rocher: 0.6 Restitution: 0.1 Densité du bois: 0.3 Restitution: 0.2 Densité du métal: 1.2 Restitution: 0.05 Densité BouncyBall: 0.3 Restitution: 0.8 Densité SuperBall: 0.3 Restitution: 0.95 Densité Oreiller: 0.1 Restitution: 0.2 Densité statique: 0.0 Densité: 0.0
Il y a encore une chose à parler dans le corps
structure. Il y a un membre de données appelé Obliger
. Cette valeur commence à zéro au début de chaque mise à jour physique. D'autres influences dans le moteur physique (comme la gravité) ajouteront Vec2
vecteurs dans cette Obliger
membre de données. Juste avant l'intégration, toute cette force sera utilisée pour calculer l'accélération du corps et sera utilisée pendant l'intégration. Après intégration cela Obliger
le membre de données est mis à zéro.
Cela permet à un nombre quelconque de forces d'agir sur un objet à tout moment, et aucun code supplémentaire ne sera nécessaire pour l'écriture lorsque de nouveaux types de forces doivent être appliqués aux objets..
Prenons un exemple. Supposons que nous ayons un petit cercle représentant un objet très lourd. Ce petit cercle vole dans le jeu et il est si lourd qu’il attire légèrement les autres objets vers lui. Voici un pseudocode approximatif pour illustrer ceci:
Objet HeavyObject pour body dans le jeu do if (object.CloseEnoughTo (body) object.ApplyForcePullOn (body)
La fonction ApplyForcePullOn ()
pourrait peut-être appliquer une petite force pour tirer la corps
vers la HeavyObject
, seulement si le corps
est assez proche.
Peu importe le nombre de forces ajoutées à la Obliger
d’un corps, car ils vont tous s’additionner pour former un seul vecteur de force pour ce corps. Cela signifie que deux forces agissant sur le même corps peuvent potentiellement s’annuler.
Dans le précédent article de cette série, des routines de détection de collision ont été introduites. Ces routines étaient en fait en dehors de ce qu'on appelle la "phase étroite". Les différences entre phase large et phase étroite peuvent être recherchées assez facilement avec une recherche Google.
(En résumé: nous utilisons une détection de collision de phase large pour déterminer quelles paires d'objets pourrait être en collision, puis la détection de collision de phase étroite pour vérifier si elles réellement sont collision.)
Je voudrais donner quelques exemples de code avec une explication sur la façon de mettre en œuvre une large phase de \ (O (n ^ 2) \) calculs de paires temps-complexité.
\ (O (n ^ 2) \) signifie essentiellement que le temps nécessaire pour vérifier chaque paire de collisions potentielles dépendra du carré du nombre d'objets. Il utilise la notation Big-O.Puisque nous travaillons avec des paires d'objets, il sera utile de créer une structure comme celle-ci:
struct Pair body * A; corps * B; ;
Une phase large devrait rassembler un tas de collisions possibles et les stocker toutes dans Paire
structures. Ces paires peuvent ensuite être transmises à une autre partie du moteur (phase étroite), puis résolues.
Exemple de phase large:
// Génère la liste des paires. // Toutes les paires précédentes sont effacées lorsque cette fonction est appelée. void BroadPhase :: GeneratePairs (void) pairs.clear () // Espace de cache pour les AABB à utiliser dans le calcul // du cadre de sélection de chaque forme AABB A_aabb AABB B_aabb pour (i = body.begin (); i! = corps) .end (); i = i-> suivant) pour (j = body.begin (); j! = organismes.end (); j = j-> suivant) Body * A = & i-> GetData () Corps * B = & j-> GetData () // Ignorer la vérification avec soi-même si (A == B) continue A-> ComputeAABB (& A_abab) B-> ComputeAABB (& B_aabb) si (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back ( UN B )
Le code ci-dessus est assez simple: vérifiez chaque corps contre chaque corps et évitez les auto-vérifications..
Il y a un problème de la dernière section: plusieurs paires dupliquées seront retournées! Ces doublons doivent être choisis parmi les résultats. Une certaine familiarité avec les algorithmes de tri sera nécessaire ici si vous ne disposez pas d'une sorte de bibliothèque de tri disponible. Si vous utilisez C ++, vous avez de la chance:
// Trier les paires pour exposer les doublons sort (pairs, pairs.end (), SortPairs); // Collecteurs de files d'attente pour résoudre int i = 0; alors que je < 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! = Potentiel_dup-> B || paire-> B! = potentiel_dup-> A) pause; ++ i;
Après avoir trié toutes les paires dans un ordre spécifique, on peut supposer que toutes les paires du paires
conteneur aura tous les doublons adjacents les uns aux autres. Placez toutes les paires uniques dans un nouveau conteneur appelé paires uniques
, et le travail d'élimination des doublons est terminé.
La dernière chose à mentionner est le prédicat SortPairs ()
. Ce SortPairs ()
fonction est ce qui est réellement utilisé pour faire le tri, et cela pourrait ressembler à ceci:
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;Les termes
lhs
et rhs
peut être lu comme "côté gauche" et "côté droit". Ces termes sont couramment utilisés pour faire référence à des paramètres de fonctions où les choses peuvent logiquement être considérées comme les côtés gauche et droit d’une équation ou d’un algorithme. Superposition se réfère à l'acte d'avoir différents objets jamais entrer en collision les uns avec les autres. Ceci est essentiel pour que les balles tirées de certains objets n'affectent pas certains autres objets. Par exemple, les joueurs d'une équipe peuvent vouloir que leurs fusées fassent du mal à leurs ennemis mais pas les unes aux autres..
La stratification est mieux mise en œuvre avec bitmasks - Pour plus d'informations, reportez-vous à la section Procédure rapide concernant les masques de bits pour les programmeurs et la page Wikipedia, ainsi que la section Filtrage du manuel de Box2D pour savoir comment ce moteur utilise les masques de bits..
La superposition doit être effectuée dans la phase large. Ici, je vais simplement coller un exemple de phase large terminé:
// Génère la liste des paires. // Toutes les paires précédentes sont effacées lorsque cette fonction est appelée. void BroadPhase :: GeneratePairs (void) pairs.clear () // espace de cache pour les AABB à utiliser dans le calcul // de la boîte englobante de chaque forme .end (); i = i-> suivant) pour (j = body.begin (); j! = organismes.end (); j = j-> suivant) Body * A = & i-> GetData () Body * B = & j-> GetData () // Ignorer la vérification avec soi-même si (A == B) continue // Seuls les calques correspondants seront pris en compte si (! (A-> calques & B-> calques)) continue; A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) if (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back (A, B)
La superposition s'avère très efficace et très simple.
UNE demi-espace peut être vu comme un côté d'une ligne en 2D. Détecter si un point se trouve d'un côté ou de l'autre d'une ligne est une tâche assez commune, qui devrait être parfaitement comprise de toute personne créant son propre moteur physique. Il est dommage que ce sujet ne soit pas vraiment traité de manière significative sur Internet, du moins d'après ce que j'ai vu - jusqu'à maintenant, bien sûr.!
L'équation générale d'une ligne en 2D est:
\ [Équation 4: \\
Général \: forme: ax + by + c = 0 \\
Normal \: to \: line: \ begin bmatrix
une \\
b \\
\ end bmatrix \]
Notez que, malgré son nom, le vecteur normal n'est pas nécessairement normalisé (c'est-à-dire qu'il n'a pas nécessairement une longueur de 1).
Pour voir si un point se trouve sur un côté particulier de cette ligne, il suffit de le brancher sur le X
et y
variables dans l'équation et vérifier le signe du résultat. Un résultat de 0 signifie que le point est sur la ligne, et positif / négatif signifie différents côtés de la ligne..
C'est tout ce qu'il y a à faire! Sachant cela, la distance entre un point et une ligne est en réalité le résultat du test précédent. Si le vecteur normal n'est pas normalisé, le résultat sera mis à l'échelle par la magnitude du vecteur normal.
A présent, un moteur physique complet, bien que simple, peut être entièrement construit à partir de rien. Des sujets plus avancés tels que le frottement, l'orientation et l'arborescence AABB dynamique peuvent être abordés dans les prochains tutoriels. S'il vous plaît poser des questions ou fournir des commentaires ci-dessous, j'aime lire et y répondre!