În această parte a seriei despre crearea unui motor personalizat de fizică 2D pentru jocurile tale, vom adăuga mai multe caracteristici la rezoluția de impuls pe care am lucrat-o în prima parte. În special, vom examina integrarea, testarea temporală, utilizarea unui model modular pentru codul nostru și detectarea coliziunii în fază lungă.
În ultimul post din această serie am abordat subiectul rezolvării impulsurilor. Citiți mai întâi acest lucru, dacă nu ați făcut-o deja!
Să ne aruncăm direct în subiectele abordate în acest articol. Aceste subiecte sunt toate necesitățile oricăror motoare de fizică pe jumătate decente, deci acum este momentul potrivit pentru a construi mai multe caracteristici pe lângă rezoluția de bază din ultimul articol.
Integrarea este foarte simplu de implementat și există foarte multe domenii pe internet care oferă informații bune pentru integrarea iterativă. Această secțiune va arăta mai ales cum să implementați o funcție de integrare adecvată și să îndreptați spre anumite locații pentru citirea ulterioară, dacă doriți.
Mai întâi trebuie să știm ce înseamnă accelerarea. Legea a doua a lui Newton prevede:
\ [Ecuația 1: \\
F = ma \]
Aceasta afirmă că suma tuturor forțelor care acționează asupra unui obiect este egală cu masa obiectului respectiv m
înmulțită cu accelerarea sa A
. m
este în kilograme, A
este în metri / secundă și F
este în Newtons.
Rearanjarea acestei ecuații un pic pentru a rezolva problema A
randamentele:
\ [Ecuația 2: \\
a = \ frac F m \\
\prin urmare\\
a = F * \ frac 1 m \]
Următorul pas implică utilizarea accelerației pentru a pasi un obiect de la o locație la alta. Deoarece jocul este afișat în cadre separate separate într-o animație asemănătoare iluziei, trebuie calculate locațiile fiecărei poziții la acești pași discrete. Pentru o acoperire mai profundă a acestor ecuații, vă rugăm să consultați: Demonstrația integrării Erin Catto de la GDC 2009 și adăugarea lui Hannu la Euler simplectic pentru o mai mare stabilitate în medii FPS scăzute.
Explicația Euler (pronunțată "oiler") este prezentată în următorul fragment, unde X
este poziția și v
este viteza. Vă rugăm să rețineți că 1 / m * F
este accelerația, după cum sa explicat mai sus:
// Explicat Euler x + = v * dt v + = (1 / m * F) * dt
dt
aici se referă la timpul delta. Δ este simbolul pentru delta și poate fi citit literal ca "schimbare în" sau scris ca ΔT
. Deci, ori de câte ori vedeți dt
poate fi citit ca "schimbare în timp". DV
ar fi "schimbarea vitezei". Acest lucru va funcționa și este folosit în mod obișnuit ca punct de plecare. Cu toate acestea, are inexactități numerice pe care le putem scăpa fără efort suplimentar. Iată ceea ce este cunoscut sub numele de Euler Simplu:
// Simplectic Euler v + = (1 / m * F) * dt x + = v * dt
Rețineți că tot ce am făcut a fost să rearanjăm ordinea celor două linii de cod - a se vedea "> articolul menționat mai sus de la Hannu.
Această postare explică inexactitățile numerice ale Euler explicite, dar trebuie avertizat că începe să acopere RK4, pe care nu o recomand personal: gafferongames.com: Euler Inaccuracy.
Aceste ecuații simple sunt tot ceea ce avem nevoie pentru a muta toate obiectele în jurul valorii de viteza liniară și accelerație.
Deoarece jocurile sunt afișate la intervale de timp discrete, trebuie să existe o modalitate de a manipula timpul între acești pași într-o manieră controlată. Ați văzut vreodată un joc care va rula la viteze diferite în funcție de ce computer se joacă? Acesta este un exemplu de joc care rulează la o viteză dependentă de capacitatea computerului de a rula jocul.
Avem nevoie de o modalitate de a ne asigura că motorul fizicii noastre rulează numai când a trecut o anumită perioadă de timp. În acest fel, dt
care este utilizat în cadrul calculelor este întotdeauna exact același număr. Utilizând exact același lucru dt
valoarea în codul dvs. peste tot va face de fapt motorul fizicii determinat, și este cunoscut ca a ora fixă. Ăsta este un lucru bun.
Un motor fizic determinist este unul care va face întotdeauna exact același lucru de fiecare dată când este rulat presupunând că sunt date aceleași intrări. Acest lucru este esențial pentru multe tipuri de jocuri în care jocul trebuie să fie foarte bine adaptat comportamentului motorului fizicii. Acest lucru este, de asemenea, esențial pentru depanarea motorului dvs. de fizică, deoarece pentru a identifica bug-uri, comportamentul motorului dvs. trebuie să fie consecvent.
Să acoperim mai întâi o versiune simplă a unui timestep fix. Iată un exemplu:
const float fps = 100 const float dt = 1 / fps float accumulator = 0 // în unități de secunde float frameStart = GetCurrentTime () // bucla principală în timp ce (true) const float currentTime = GetCurrentTime ultimul cadru a început acumulatorul + = currentTime - frameStart () // Înregistrați începutul acestui cadru frameStart = currentTime în timp ce (accumulator> dt) UpdatePhysics (dt) accumulator - = dt RenderGame
Aceasta așteaptă în jur, făcând jocul, până când a trecut suficient timp pentru actualizarea fizicii. Timpul scurs este înregistrat și discret dt
-bucati de timp de dimensiuni sunt luate din acumulator si prelucrate de fizica. Acest lucru asigură că exact aceeași valoare este trecută la fizică indiferent de ce, și că valoarea trecută fizicii este o reprezentare exactă a timpului real care trece prin viața reală. Bucăți de dt
sunt eliminate din acumulator
pană la acumulator
este mai mică decât a dt
bucată mare.
Există câteva probleme care pot fi rezolvate aici. Primul implică cât timp este nevoie să efectuați actualizarea actualizării fizicii: Ce se întâmplă dacă actualizarea fizică durează prea mult și acumulator
merge mai sus și mai mare fiecare buclă de joc? Aceasta se numește spirala morții. Dacă acest lucru nu este stabilit, motorul dvs. se va rupe rapid până la oprirea completă dacă fizica dvs. nu poate fi efectuată suficient de repede.
Pentru a rezolva acest lucru, motorul trebuie să ruleze doar puține actualizări de fizică în cazul în care acumulator
devine prea mare. O modalitate simplă de a face acest lucru ar fi să strângeți acumulator
sub o anumită valoare arbitrară.
const float fps = 100 const float dt = 1 / fps float acumulator = 0 // în unități secunde float frameStart = GetCurrentTime () // bucla principală în timp ce (true) const float currentTime = GetCurrentTime ultimul cadru a început acumulatorul + = currentTime - frameStart () // Înregistrați începutul acestui cadru frameStart = currentTime // Evitați spirala morții și clema dt, prindeți astfel // de câte ori se poate apela UpdatePhysics în // un singur joc buclă. dacă (acumulator> 0.2f) acumulator = 0.2f în timp ce (acumulator> dt) UpdatePhysics (dt) acumulator - = dt RenderGame
Acum, dacă un joc care rulează această buclă întâlnește vreodată un fel de împiedicare din orice motiv, fizica nu se va îneca într-o spirală a morții. Jocul se va executa pur și simplu puțin mai încet, după caz.
Următorul lucru pe care trebuie să-l rezolvi este destul de mic în comparație cu spirala morții. Această buclă ia dt
bucăți din acumulator
pană la acumulator
este mai mică decât dt
. Este distractiv, dar mai rămâne un pic de timp rămas în acumulator
. Aceasta reprezintă o problemă.
Să presupunem că acumulator
este lăsat cu 1 / 5th of a dt
bucata pe fiecare cadru. În al șaselea cadru acumulator
va avea suficient timp rămas pentru a efectua o actualizare fizică mai mult decât toate celelalte cadre. Acest lucru va avea ca rezultat un cadru în fiecare secundă sau o astfel de performanță un salt ușor mai mare discret în timp, și ar putea fi foarte vizibil în joc.
Pentru a rezolva acest lucru, utilizarea interpolare liniară este necesară. Dacă aceasta pare a fi înfricoșătoare, nu vă faceți griji - implementarea va fi afișată. Dacă doriți să înțelegeți implementarea, există multe resurse online pentru interpolarea liniară.
// interpolare liniară pentru a de la 0 la 1 // de la t1 la t2 t1 * a + t2 (1.0f - a)
Folosind acest lucru putem interpola (aproximati) unde am putea fi intre doua intervale de timp diferite. Acest lucru poate fi folosit pentru a face starea jocului între două actualizări fizice diferite.
Cu interpolare liniară, randarea unui motor poate funcționa într-un ritm diferit față de motorul fizicii. Acest lucru permite o manipulare grațioasă a rămășițelor acumulator
din actualizările fizicii.
Iată un exemplu complet:
const float fps = 100 const float dt = 1 / fps float acumulator = 0 // în unități secunde float frameStart = GetCurrentTime () // bucla principală în timp ce (true) const float currentTime = GetCurrentTime ultimul cadru a început acumulatorul + = currentTime - frameStart () // Înregistrați începutul acestui cadru frameStart = currentTime // Evitați spirala morții și clema dt, prindeți astfel // de câte ori se poate apela UpdatePhysics în // un singur joc buclă. dacă (acumulator> 0.2f) acumulator = 0.2f în timp ce (acumulator> dt) UpdatePhysics (dt) acumulator - = dt const float alfa = acumulator / dt; RenderGame (alpha) void RenderGame (float alfa) pentru forma în joc nu // calcula o transformare interpolată pentru randare Transformare i = shape.previous * alpha + shape.current * (1.0f - alpha) shape.previous = shape.current shape .Render (i)
Aici, toate obiectele din joc pot fi desenate la momente variabile între momentele fizice discrete. Acest lucru se va ocupa gratios de toate erorile și acumularea de timp rămase. Acest lucru este de fapt redare tot atat de usor in spatele ceea ce fizica a rezolvat in mod curent, dar cand privim jocul rulati toate miscarile sunt slefuite perfect de interpolarea.
Jucătorul nu va ști niciodată că randarea este atât de ușor în spatele fizicii, deoarece jucătorul va ști doar ce văd și ceea ce vor vedea este o tranziție perfect netedă de la un cadru la altul.
S-ar putea să te întrebi "de ce nu interpolăm de la poziția curentă la următoarea?". Am încercat acest lucru și am nevoie de redare pentru a "ghici" unde vor fi obiecte în viitor. Adesea, obiectele dintr-un motor de fizică fac schimbări bruște în mișcare, cum ar fi în timpul unei coliziuni, și când se produce o astfel de schimbare bruscă a mișcării, obiectele se vor teleporta din cauza unor interpolări inexacte în viitor.
Există câteva lucruri pe care fiecare obiect de fizică va avea nevoie. Cu toate acestea, lucrurile specifice ale fiecărui obiect fizic se pot schimba ușor de la obiect la obiect. Este necesar un mod inteligent de a organiza toate aceste date și s-ar presupune că se dorește cantitatea mai mică de cod pentru a realiza o astfel de organizare. În acest caz, un design modular ar fi de folos.
Modulul de design pare să fie puțin pretențios sau prea complicat, dar are sens și este destul de simplu. În acest context, "designul modular" înseamnă doar că vrem să distrugem un obiect fizic în bucăți separate, astfel încât să le putem conecta sau să le deconectăm, totuși considerăm potrivit.
Un corp de fizică este un obiect care conține toate informațiile despre un obiect fizic dat. Acesta va stoca forma (ele) la care este reprezentat obiectul, datele de masă, transformarea (poziția, rotația), viteza, cuplul și așa mai departe. Iată ce ne corp
Ar trebui să arate:
structură corp formă * formă; Transformați tx; Materiale materiale; MassData mass_data; Vec2 viteza; Vec2 forță; greutate reală; ;
Acesta este un bun punct de plecare pentru proiectarea unei structuri de corp fizic. Există câteva decizii inteligente făcute aici care tind spre o organizare puternică a codului.
Primul lucru pe care trebuie să-l observați este că o formă este cuprinsă în interiorul corpului prin intermediul unui indicator. Aceasta reprezintă o relație slabă între corp și forma sa. Un corp poate conține orice formă, iar forma unui corp poate fi schimbată la alegere. De fapt, un corp poate fi reprezentat prin mai multe forme, și un astfel de corp ar fi cunoscut ca un "compozit", deoarece ar fi compus din mai multe forme. (Eu nu am de gând să acopere compozite în acest tutorial.)
Interfață corp și formă. formă
ea însăși este responsabilă de calcularea formelor de legare, de calcularea masei pe baza densității și de randare.
mass_data
este o structură mică de date care conține informații legate de masă:
struct MassData float mass; float inv_mass; // Pentru rotații (neacoperite în acest articol) inerția plutitoare; float inverse_inertia; ;
Este frumos să stocați toate valorile legate de masă și interțime într-o singură structură. Masa nu trebuie niciodată setată manual - masa ar trebui să fie întotdeauna calculată de forma însăși. Mass-ul este un tip de valoare mai degrabă neintuitiv, iar setarea manuală va dura mult timp de tweaking. Este definit ca:
\ [Ecuația 3: \\ Masă = densitate * volum \]
Ori de câte ori un designer dorește ca o formă să fie mai "masivă" sau "grea", ar trebui să modifice densitatea unei forme. Această densitate poate fi utilizată pentru a calcula masa unei forme având în vedere volumul acesteia. Aceasta este modalitatea corectă de abordare a situației, deoarece densitatea nu este afectată de volum și nu se va schimba niciodată în timpul runtime-ului jocului (dacă nu este în mod special susținut cu un cod special).
Câteva exemple de forme cum ar fi AABB și Cercuri pot fi găsite în tutorialul anterior din această serie.
Toate aceste vorbe de masă și densitate conduc la întrebarea: Unde este valoarea densității? Se află în interiorul Material
structura:
struct Material float density; restituirea restituirii; ;
Odată ce valorile materialului sunt stabilite, acest material poate fi trecut la forma unui corp astfel încât corpul să poată calcula masa.
Ultimul lucru demn de menționat este gravity_scale
. Scalarea gravitației pentru obiecte diferite este adesea necesară pentru a schimba modul de joc în care este mai bine să includeți o valoare în fiecare organism în mod special pentru această sarcină.
Unele setări de materiale utile pentru tipurile de materiale comune pot fi folosite pentru a construi o Material
obiect dintr-o valoare de enumerare:
Densitatea Rockului: 0.6 Restituirea: 0.1 Densitatea lemnului: 0.3 Restabilirea: 0.2 Densitatea metalului: 1.2 Restituirea: 0.05 BouncyBall Densitatea: 0.3 Restituirea: 0.8 Densitatea SuperBall: 0.3 Restituirea: 0.95 Densitatea pernei: 0.1 Restituirea: 0.2 Densitatea statică: 0.0 Restituirea: 0.4
Există încă un lucru despre care să vorbim în corp
structura. Există un membru de date numit forta
. Această valoare începe de la zero la începutul fiecărei actualizări a fizicii. Vor fi adăugate și alte influențe în motorul fizicii (cum ar fi gravitatea) Vec2
vectori în acest sens forta
membru de date. Chiar înainte de integrare, toată această forță va fi utilizată pentru a calcula accelerația corpului și va fi utilizată în timpul integrării. După integrare forta
membrul de date este anulat.
Acest lucru permite ca orice număr de forțe să acționeze asupra unui obiect ori de câte ori se consideră potriviți și nu va fi necesar să se scrie un cod suplimentar atunci când trebuie aplicate noi tipuri de forțe asupra obiectelor.
Să luăm un exemplu. Spuneți că avem un cerc mic care reprezintă un obiect foarte greu. Acest cerc mic zboară în jurul valorii de joc și este atât de greu încât atrage alte obiecte către el într-atât de ușor. Iată câteva pseudocode dure pentru a demonstra acest lucru:
Obiectul HeavyObject pentru corp în joc face dacă (object.CloseEnoughTo (body) object.ApplyForcePullOn (body)
Functia ApplyForcePullOn ()
ar putea aplica o forță mică pentru a trage corp
catre HeavyObject
, numai dacă corp
este destul de aproape.
Nu contează cât de multe forțe sunt adăugate la forta
a unui corp, deoarece toți vor adăuga un singur vector de forță sumat pentru acel corp. Aceasta înseamnă că două forțe care acționează asupra aceluiași corp se pot anula reciproc.
În articolul precedent din această serie au fost introduse rutinele de detectare a coliziunii. Aceste rutine erau de fapt în afară de ceea ce se numește "faza îngustă". Diferențele dintre faza largă și faza îngustă pot fi cercetate destul de ușor cu o căutare Google.
(Pe scurt: utilizăm detecția coliziunii în fază largă pentru a afla care perechi de obiecte ar putea să se ciocnească și apoi să detecteze coliziunea în fază îngustă pentru a verifica dacă acestea sunt de fapt sunteți coliziunea.)
Aș dori să ofer un exemplu de cod împreună cu o explicație a modului de implementare a unei faze largi a calculelor perechi de timp \ (O (n ^ 2) \).
\ (O (n ^ 2) \) înseamnă în esență că timpul necesar pentru a verifica fiecare pereche de potențiale coliziuni va depinde de pătratul numărului de obiecte. Utilizează notația Big-O.Deoarece lucrăm cu perechi de obiecte, va fi util să creăm o structură cum ar fi:
struct Pair corp * A; corp * B; ;
O fază largă ar trebui să colecteze o grămadă de coliziuni posibile și să le stocheze pe toate Pereche
structuri. Aceste perechi pot apoi să fie transferate într-o altă porțiune a motorului (faza îngustă) și apoi rezolvate.
Exemplu de fază largă:
// generează lista de perechi. // Toate perechile anterioare sunt șterse când această funcție este apelată. void BroadPhase :: GeneratePairs (void) pairs.clear () // Spațiu cache pentru AABBs pentru a fi folosit în calculul // din caseta de margine a fiecărei forme AABB A_aabb AABB B_aabb pentru (i = bodies.begin (); i! = bodies () = j = corpuri.end (); i = i-> urmatorul) Body = A = & i-> GetData (A = B) continuați A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) dacă (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back (A, B)
Codul de mai sus este foarte simplu: verificați fiecare corp împotriva fiecărui corp și treceți de auto-verificări.
Există o problemă din ultima secțiune: multe perechi duplicate vor fi returnate! Aceste duplicate trebuie să fie eliminate din rezultate. Unele familiarizări cu algoritmii de sortare vor fi cerute aici dacă nu aveți la dispoziție un fel de bibliotecă de sortare. Dacă utilizați C ++ atunci sunteți în noroc:
// sortează perechi pentru a expune sortarea duplicatelor (perechi, pairs.end (), SortPairs); // Colecții de coadă pentru rezolvarea int i = 0; in timp ce eu < 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! = Potențial_dup-> B || pereche-> B! = potential_dup-> A) pauză; ++ i;
După sortarea tuturor perechilor într - o anumită ordine, se poate presupune că toate perechile din perechi
containerul va avea toate duplicatele adiacente unul altuia. Plasați toate perechile unice într-un container nou numit uniquePairs
, iar lucrarea de sacrificare a duplicatelor sa terminat.
Ultimul lucru de menționat este predicatul SortPairs ()
. Acest SortPairs ()
este ceea ce este de fapt folosit pentru a face sortarea și ar putea arăta astfel:
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;Termenii
LHS
și RHS
poate fi citit ca "partea stângă" și "partea dreaptă". Acești termeni sunt folosiți în mod obișnuit pentru a se referi la parametrii funcțiilor în care lucrurile pot fi logic văzute ca partea stângă și dreaptă a unor ecuații sau algoritmi. stratificarea se referă la faptul că obiectele diferite nu se ciocnesc niciodată unul cu altul. Aceasta este cheia pentru ca gloanțele trase de anumite obiecte să nu afecteze anumite alte obiecte. De exemplu, jucătorii unei echipe ar putea dori ca rachetele lor să dăuneze inamicilor, dar nu reciproc.
Layeringul este cel mai bine implementat cu bitmasks - consultați Cum se instalează Cum se procedează Bitmasm rapid pentru programatori și pagina Wikipedia pentru o introducere rapidă și secțiunea Filtrare a manualului Box2D pentru a vedea cum utilizează acest motor motoarele bitmaps.
Amplasarea trebuie făcută în faza largă. Aici voi lipi doar un exemplu terminat de fază largă:
// generează lista de perechi. // Toate perechile anterioare sunt șterse când această funcție este apelată. void BroadPhase :: GeneratePairs (void) pairs.clear () // Spațiu cache pentru AABBs pentru a fi folosit în calculul // din caseta de margine a fiecărei forme AABB A_aabb AABB B_aabb pentru (i = bodies.begin (); i! = bodies () = j = corpuri.end (); i = i-> urmatorul) Body = A = & i-> GetData Corpul B = & j-> GetData () // Skip verifica cu sine daca (A == B) continua // Se vor lua in considerare numai straturile care se potrivesc daca continuati (! (A-> layers & B-> layers); A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) dacă (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back (A, B)
Layering-ul se dovedește a fi extrem de eficient și foarte simplu.
A halfspace pot fi văzute ca o parte a unei linii în 2D. Detectarea dacă un punct este pe o parte a liniei sau celălalt este o sarcină destul de obișnuită și ar trebui să fie înțeleasă cu atenție de oricine își creează propriul motor de fizică. Este prea rău că acest subiect nu este acoperit într-adevăr într-un mod semnificativ de oriunde pe Internet, cel puțin din ceea ce am văzut - până acum, desigur!
Ecuația generală a unei linii în 2D este:
\ [Ecuația 4: \\
General \: forma: ax + by + c = 0 \\
Normal \: la \: linia: \ begin bmatrix
A \\
b \\
\ End bmatrix \]
Rețineți că, în ciuda numelui său, vectorul normal nu este neapărat normalizat (adică nu are neapărat o lungime de 1).
Pentru a vedea dacă un punct se află pe o anumită parte a acestei linii, tot ceea ce trebuie să faceți este să conectați punctul la X
și y
variabilele din ecuație și verificați semnul rezultatului. Un rezultat de 0 înseamnă că punctul este pe linie, iar pozitiv / negativ înseamnă diferite laturi ale liniei.
Asta este tot ce există! Știind aceasta distanța de la un punct la linie este de fapt rezultatul testului anterior. Dacă vectorul normal nu este normalizat, atunci rezultatul va fi scalat de magnitudinea vectorului normal.
Până acum, un motor fizic complet, deși simplu, poate fi construit în întregime de la zero. Subiecte mai avansate, cum ar fi fricțiunea, orientarea și arborele dinamic AABB, pot fi acoperite în tutoriale viitoare. Vă rugăm să puneți întrebări sau să oferiți comentarii mai jos, îmi place să citesc și să le răspund!