Slik oppretter du en tilpasset 2D-fysikkmotor Kjernemotoren

I denne delen av serien min om å skape en tilpasset 2D fysikkmotor for spillene dine, legger vi til flere funksjoner i impulsoppløsningen vi jobbet i første del. Spesielt ser vi på integrasjon, tidsbestemmelse, bruk av en modulær design for koden vår og brede fase kollisjon gjenkjenning.


Introduksjon

I det siste innlegget i denne serien dekket jeg emnet for impulsoppløsning. Les det først, hvis du ikke allerede har det!

La oss dykke rett inn i emnene dekket i denne artikkelen. Disse emnene er alle nødvendigheter til enhver halvt anstendig fysikkmotor, så nå er det en passende tid å bygge flere funksjoner på toppen av kjerneoppløsningen fra den siste artikkelen.

  • Integrering
  • Timestepping
  • Modular Design
    • Bodies
    • figurer
    • Krefter
    • materialer
  • Bred fase
    • Kontaktpar duplikatutkasting
    • lagdeling
  • Halfspace Intersection Test

Integrering

Integrasjon er helt enkelt å implementere, og det er mange områder på internett som gir god informasjon for iterativ integrasjon. Denne delen vil for det meste vise hvordan du implementerer en skikkelig integrasjonsfunksjon, og pek på noen forskjellige steder for videre lesning, om ønskelig.

Først bør det være kjent hvilken akselerasjon faktisk er. Newtons andre lov sier:

\ [Ligning 1: \\
F = ma \]

Dette sier at summen av alle krefter som virker på et objekt, er lik den objektets masse m multiplisert med akselerasjonen en. m er i kilo, en er i meter / sekund og F er i newtons.

Omarrangere denne ligningen litt for å løse for en gir:

\ [Ligning 2: \\
a = \ frac F m \\
\derfor\\
a = F * \ frac 1 m \]

Det neste trinnet innebærer bruk av akselerasjon for å få en objekt fra et sted til et annet. Siden et spill vises i diskrete separate rammer i en illusjonlignende animasjon, må plasseringen av hver posisjon ved disse diskrete trinnene beregnes. For en mer grundig dekning av disse ligningene, se: Erin Cattos integrasjonsdemo fra GDC 2009 og Hannus tillegg til symplectisk Euler for mer stabilitet i lave FPS-miljøer.

Eksplisitt Euler (uttalte "oiler") -integrasjon er vist i følgende utdrag, hvor x er posisjon og v er hastighet. Vær oppmerksom på at 1 / m * F er akselerasjon, som forklart ovenfor:

 // Eksplisitt Euler x + = v * dt v + = (1 / m * F) * dt
dt her refererer til delta tid. Δ er symbolet for delta, og kan leses bokstavelig talt som "endring i", eller skrevet som Δt. Så når du ser dt det kan leses som "forandring i tid". dv ville være "endring i hastighet".

Dette vil fungere, og brukes ofte som utgangspunkt. Det har imidlertid numeriske unøyaktigheter som vi kan bli kvitt uten ekstra innsats. Her er det som kalles Symplectic Euler:

 // Symptisk Euler v + = (1 / m * F) * dt x + = v * dt

Merk at alt jeg gjorde var omarrangere rekkefølgen til de to kodelinjene - se "> den nevnte artikkelen fra Hannu.

Dette innlegget forklarer de numeriske unøyaktighetene til Explicit Euler, men vær advart om at han begynner å dekke RK4, som jeg ikke personlig anbefaler: gafferongames.com: Euler Unøyaktighet.

Disse enkle ligningene er alt vi trenger for å flytte alle objekter rundt med lineær hastighet og akselerasjon.


Timestepping

Siden spill vises på diskrete tidsintervaller, må det være en måte å manipulere tiden mellom disse trinnene på en kontrollert måte. Har du noen gang sett et spill som vil kjøre med forskjellige hastigheter avhengig av hvilken datamaskin den blir spilt på? Det er et eksempel på et spill som kjører med en hastighet avhengig av datamaskinens evne til å kjøre spillet.

Vi trenger en måte å sikre at vår fysikkmotor bare kjører når en bestemt tid har gått. På denne måten, dt som brukes innen beregninger er alltid det samme nummeret. Bruk nøyaktig samme dt verdien i koden din overalt vil faktisk lage din fysikkmotor determinis, og er kjent som en fast tidstest. Dette er en god ting.

En deterministisk fysikkmotor er en som alltid vil gjøre nøyaktig samme ting hver gang det kjøres dersom de samme inngangene blir gitt. Dette er avgjørende for mange typer spill hvor spillingen må være veldig finjustert til fysikkmotorens oppførsel. Dette er også viktig for feilsøking av din fysikkmotor, for å kunne fastslå feil må oppførelsen til motoren være konsekvent.

La oss først dekke en enkel versjon av en fast timestep. Her er et eksempel:

 const float fps = 100 const float dt = 1 / fps flyteakkumulator = 0 // I enheter av sekunder float frameStart = GetCurrentTime () // hovedløkke mens (sann) const float currentTime = GetCurrentTime () // Lagre tiden som er gått siden siste rammen begynte akkumulator + = currentTime - frameStart () // Record start av denne rammen rammeStart = currentTime mens (akkumulator> dt) UpdatePhysics (dt) akkumulator - = dt RenderGame ()

Dette venter, gjør spillet til det er nok tid til å oppdatere fysikken. Den forløpte tiden er registrert og diskret dt-Større klumper av tid tas fra akkumulatoren og behandles av fysikken. Dette sikrer at nøyaktig samme verdi overføres til fysikken uansett hva, og at verdien som overføres til fysikken, er en nøyaktig representasjon av den faktiske tiden som går forbi i det virkelige liv. Biter av dt blir fjernet fra akkumulator til akkumulator er mindre enn a dt blings.

Det er et par problemer som kan løses her. Den første innebærer hvor lang tid det tar å faktisk utføre fysikkoppdateringen: Hva om fysikkoppdateringen tar for lang tid og akkumulator går høyere og høyere hver spillsløyfe? Dette kalles spiral av døden. Hvis dette ikke er løst, vil motoren raskt slipes helt hvis fysikken ikke kan utføres raskt nok.

For å løse dette, må motoren virkelig bare kjøre færre fysikkoppdateringer hvis akkumulator blir for høyt. En enkel måte å gjøre dette på er å klemme på akkumulator under noen vilkårlig verdi.

 const float fps = 100 const float dt = 1 / fps float akkumulator = 0 // I enheter sekunder float frameStart = GetCurrentTime () // main loop mens (true) const float currentTime = GetCurrentTime () // Lagre tiden som er gått siden siste ramme begynte akkumulator + = currentTime - frameStart () // Record start av denne rammen rammeStart = currentTime // Unngå spiral av død og klemme dt, og dermed klemme // hvor mange ganger UpdatePhysics kan bli kalt inn // et enkelt spill sløyfe. hvis (akkumulator> 0.2f) akkumulator = 0.2f mens (akkumulator> dt) UpdatePhysics (dt) akkumulator - = dt RenderGame ()

Nå, hvis et spill som kjører denne sløyfen, noen gang opplever en slags stalling uansett grunn, vil fysikken ikke drukne seg i en spiral av døden. Spillet vil bare løpe litt langsommere, etter behov.

Den neste tingen å fikse er ganske liten i forhold til dødens spiral. Denne sløyfen tar dt biter fra akkumulator til akkumulator er mindre enn dt. Dette er gøy, men det er fortsatt litt gjenværende tid igjen i akkumulator. Dette er et problem.

Anta akkumulator er igjen med 1/5 av a dt klump hver ramme. På den sjette rammen på akkumulator vil ha nok gjenværende tid til å utføre en fysikkoppdatering enn alle de andre rammene. Dette vil resultere i en ramme hvert sekund eller så utføre et litt større diskret hoppe i tid, og kan være svært merkbar i spillet ditt.

For å løse dette, bruk av Lineær interpolering er nødvendig. Hvis dette høres skummelt, ikke bekymre deg - implementeringen vil bli vist. Hvis du vil forstå implementeringen, er det mange ressurser online for lineær interpolering.

 // lineær interpolering for a fra 0 til 1 // fra t1 til t2 t1 * a + t2 (1.0f - a)

Ved hjelp av dette kan vi interpolere (omtrentlig) hvor vi kan være mellom to forskjellige tidsintervaller. Dette kan brukes til å gjøre tilstanden til et spill mellom to forskjellige fysikkoppdateringer.

Med lineær interpolering kan rendering av en motor kjøre i et annet tempo enn fysikkmotoren. Dette gir en grasiøs håndtering av leftover akkumulator fra fysikkoppdateringene.

Her er et fullstendig eksempel:

 const float fps = 100 const float dt = 1 / fps float akkumulator = 0 // I enheter sekunder float frameStart = GetCurrentTime () // main loop mens (true) const float currentTime = GetCurrentTime () // Lagre tiden som er gått siden siste ramme begynte akkumulator + = currentTime - frameStart () // Record start av denne rammen rammeStart = currentTime // Unngå spiral av død og klemme dt, og dermed klemme // hvor mange ganger UpdatePhysics kan bli kalt inn // et enkelt spill sløyfe. hvis (akkumulator> 0.2f) akkumulator = 0.2f mens (akkumulator> dt) UpdatePhysics (dt) akkumulator - = dt const float alpha = akkumulator / dt; RenderGame (alfa) void RenderGame (float alpha) for form i spillet gjør // beregne en interpolert transformasjon for gjengivelse Transform i = shape.previous * alfa + shape.current * (1.0f - alfa) form.previous = shape.current form .Render (i)

Her kan alle objekter i spillet trekkes på variable øyeblikk mellom diskrete fysikk tidspunkter. Dette vil gracefully håndtere all feil og gjenværende tid akkumulering. Dette gjengir faktisk noe så lite bak det som fysikken har løst for tiden, men når man ser på spillkjøringen, glattes alle bevegelser perfekt ut av interpoleringen.

Spilleren vil aldri vite at gjengivelsen er så liten bak fysikken, fordi spilleren bare vil vite hva de ser, og hva de ser er perfekte glatte overganger fra en ramme til en annen.

Du lurer kanskje på, "hvorfor interpolerer vi ikke fra nåværende posisjon til neste?". Jeg prøvde dette og det krever gjengivelsen å "gjette" hvor objekter kommer til å være i fremtiden. Ofte gjør objekter i en fysikkmotor plutselige endringer i bevegelse, for eksempel under kollisjon, og når en slik plutselig bevegelsesendring er gjort, vil objekter teleportere rundt på grunn av unøyaktige interpolasjoner inn i fremtiden.


Modular Design

Det er noen ting som hvert fysikkobjekt kommer til å trenge. Imidlertid kan de spesifikke tingene hver fysikkobjekt trenger, endres litt fra objekt til objekt. En smart måte å organisere alle disse dataene på er nødvendig, og det ville antas at den mindre mengden kode som skal skrives for å oppnå en slik organisasjon, er ønsket. I dette tilfellet ville noe modulært design være til nytte.

Modulær design høres nokså lite pretensiøs eller overkomplisert, men det gir mening og er ganske enkelt. I denne sammenheng betyr "modulær design" bare at vi ønsker å bryte et fysikkobjekt i separate stykker, slik at vi kan koble eller koble fra dem, men vi ser det som passer.

Bodies

En fysikk kropp er et objekt som inneholder all informasjon om et gitt fysikkobjekt. Det vil lagre formen (e) som objektet er representert av, massedata, transformasjon (posisjon, rotasjon), hastighet, dreiemoment og så videre. Her er hva vår kropp burde se ut som:

 struktur kropp Form * form; Transform tx; Materiale materiale; MassData mass_data; Vec2 hastighet; Vec2 force; ekte tyngdekraften; ;

Dette er et flott utgangspunkt for utformingen av en fysikk kroppsstruktur. Det er noen intelligente beslutninger gjort her som har en tendens til sterk kodeorganisasjon.

Det første du må legge merke til er at en form er inneholdt i kroppen ved hjelp av en peker. Dette representerer et løs forhold mellom kroppen og dens form. En kropp kan inneholde enhver form, og formen på en kropp kan byttes om igjen. Faktisk kan en kropp representeres av flere former, og en slik kropp ville være kjent som en "sammensatt", da den ville være sammensatt av flere former. (Jeg skal ikke dekke komposittene i denne opplæringen.)

Kropp og form grensesnitt.

De form i seg selv er ansvarlig for å beregne avgrensende former, beregne masse basert på tetthet og gjengivelse.

De mass_data er en liten datastruktur som inneholder masse-relatert informasjon:

 struct MassData float mass; flyte inv_mass; // For rotasjoner (ikke dekket i denne artikkelen) svømmer tröghet; float inverse_inertia; ;

Det er hyggelig å lagre alle masse- og intertia-relaterte verdier i en enkelt struktur. Massen må aldri settes for hånd - masse skal alltid beregnes av selve formen. Masse er en ganske uintensiv type verdi, og å sette den for hånd vil ta mye tid til å tilpasse seg. Det er definert som:

\ [Likning 3: \\ Mass = tetthet * volum \]

Når en designer ønsker en form for å være mer "massiv" eller "tung", bør de endre tetthet av en form. Denne tettheten kan brukes til å beregne massen av en form gitt volumet. Dette er den riktige måten å gå om situasjonen på, fordi tetthet ikke påvirkes av volum og vil aldri forandre seg under spilletiden (med mindre det støttes spesielt med spesiell kode).

Noen eksempler på former som AABB og Circles finnes i den forrige opplæringen i denne serien.

materialer

Alt dette snakk om masse og tetthet fører til spørsmålet: Hvor ligger tetthetsverdien? Den ligger innenfor Materiale struktur:

 Struct Material float density; flytgjenoppretting; ;

Når materialets verdier er satt, kan dette materialet sendes til kroppsform slik at kroppen kan beregne massen.

Det siste som er verdt å nevne er gravity_scale. Skalering av tyngdekraften for forskjellige objekter er så ofte nødvendig for å justere gameplay at det er best å bare inkludere en verdi i hver kropp spesielt for denne oppgaven.

Noen nyttige materialinnstillinger for vanlige materialetyper kan brukes til å konstruere en Materiale objekt fra en oppregningsverdi:

 Rock tetthet: 0,6 Gjenoppretting: 0,1 Tretthet: 0,3 Gjenoppretting: 0,2 Metaldensitet: 1,2 Gjenoppretting: 0,05 BouncyBall tetthet: 0,3 Gjenoppretting: 0,8 SuperBall tetthet: 0,3 Gjenoppretting: 0,95 Pudet tetthet: 0,1 Gjenoppretting: 0,2 Statisk tetthet: 0,0 Gjenoppretting: 0,4

Krefter

Det er enda en ting å snakke om i kropp struktur. Det er et datalid som heter makt. Denne verdien starter ved null i begynnelsen av hver fysikkoppdatering. Andre påvirkninger i fysikkmotoren (som tyngdekraften) vil legge til Vec2 vektorer inn i dette makt data medlem. Like før integrasjon vil all denne kraften bli brukt til å beregne akselerasjon av kroppen, og bli brukt under integrering. Etter integrasjon dette makt Datamedlem er nullstillet.

Dette gjør det mulig for noen antall krefter å handle på et objekt når de passer, og det kreves ingen ekstra kode å bli skrevet når nye typer krefter skal påføres objekter.

La oss ta et eksempel. Si at vi har en liten sirkel som representerer en veldig tung gjenstand. Denne lille sirkelen flyr rundt i spillet, og det er så tungt at det trekker andre gjenstander mot det noen gang så lite. Her er noen grov pseudokode for å demonstrere dette:

 HeavyObject objekt for kropp i spillet gjør hvis (object.CloseEnoughTo (body) object.ApplyForcePullOn (body)

Funksjonen ApplyForcePullOn () kan kanskje bruke en liten kraft til å trekke kropp mot HeavyObject, bare hvis kropp er nær nok.


To gjenstander trukket mot en større en beveget klistret dem. Trekkstyrken er avhengig av avstanden til den større boksen.

Det spiller ingen rolle hvor mange krefter legges til makt av en kropp, da de alle vil legge opp til en enkelkalt vektvektor for den kroppen. Dette betyr at to krefter som virker på samme kropp, kan potensielt avbryte hverandre.


Bred fase

I den forrige artikkelen i denne serien ble kollisjonsdetekteringsrutiner innført. Disse rutinene var faktisk fra hverandre av det som kalles "smal fase". Forskjellene mellom bred fase og smal fase kan undersøkes ganske enkelt med et Google-søk.

(Kort sagt: vi bruker brede fase kollisjon gjenkjenning for å finne ut hvilke par av objekter kanskje kolliderer, og deretter smal fase kollisjon gjenkjenning for å sjekke om de egentlig er kolliderer).

Jeg vil gjerne gi noen eksempler på kode sammen med en forklaring på hvordan å implementere en bred fase av \ (O (n ^ 2) \) tidskompleksitetsparberegninger.

\ (O (n ^ 2) \) betyr i hovedsak at tiden for å sjekke hvert par potensielle kollisjoner vil avhenge av kvadratet av antall objekter. Den bruker Big-O notasjon.

Siden vi jobber med par av objekter, vil det være nyttig å lage en struktur som slik:

 Struct Pair body * A; kropp * B; ;

En bred fase bør samle en haug med mulige kollisjoner og lagre dem alle sammen Par strukturer. Disse parene kan deretter overføres til en annen del av motoren (den smale fase), og deretter løses.

Eksempel bred fase:

 // Genererer parlisten. // Alle tidligere par blir slettet når denne funksjonen kalles. void BroadPhase :: GeneratePairs (void) pairs.clear () // Cache-plass for AABBer som skal brukes i beregning // av hver formens avgrensningsboks AABB A_aabb AABB B_aabb for (i = bodies.begin (); i! = kropper .end (); i = i-> neste) for (j = bodies.begin (); j! = bodies.end (); j = j-> neste) Body * A = & i-> GetData Kropp * B = & j-> GetData () // Hopp sjekk med selv hvis (A == B) fortsett A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) hvis (AABBtoAABB (A_aabb, B_aabb)) par.push_back (A, B)

Koden ovenfor er ganske enkel: Kontroller hver kropp mot hver kropp, og hopp over selvkontroll.

Culling Duplicates

Det er ett problem fra den siste delen: mange dupliserte par vil bli returnert! Disse duplikatene må slettes fra resultatene. Noen kjennskap til sorteringsalgoritmer vil bli påkrevet her hvis du ikke har noen sorteringsbibliotek tilgjengelig. Hvis du bruker C ++ så har du lykke:

 // Sorter par for å avsløre duplikater sorter (par, pairs.end (), SortPairs); // Queue manifolds for å løse int i = 0; mens jeg < 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! = Potential_dup-> B || par-> B! = potential_dup-> A) break; ++ i; 

Etter at alle parene er sortert i en bestemt rekkefølge, kan det antas at alle parene i parene beholderen vil ha alle duplikater i nærheten av hverandre. Plasser alle unike parene i en ny container som heter uniquePairs, og jobben med å kaste ut duplikater er ferdig.

Det siste å nevne er predikatet Sortpairs (). Dette Sortpairs () funksjonen er hva som egentlig brukes til å gjøre sorteringen, og det kan se slik ut:

 bool SortPairs (Par lhs, Par rhs) if (lhs.A < rhs.A) return true; if(lhs.A == rhs.A) return lhs.B < rhs.B; return false; 
Vilkårene LHS og rhs kan leses som "venstre side" og "høyre side". Disse begrepene brukes ofte til å referere til parametere for funksjoner der ting logisk kan sees som venstre og høyre side av noen ligning eller algoritme.

lagdeling

lagdeling refererer til handlingen om å ha forskjellige objekter aldri kollidere med hverandre. Dette er nøkkelen for at kuler som er skutt fra bestemte objekter, ikke påvirker visse andre objekter. For eksempel kan spillere på ett lag ha sine raketter til å skade fiender, men ikke hverandre.


Representasjon av layering; noe objekt kolliderer med hverandre, noen gjør det ikke.

Layering er best implementert med bitmasks - se en hurtig bitmask-hvordan-for programmerere og Wikipedia-siden for en rask introduksjon, og filtreringsdelen av Box2D-håndboken for å se hvordan denne motoren bruker bitmasker.

Layering bør gjøres innen den brede fasen. Her legger jeg bare inn et ferdig brede fase eksempel:

 // Genererer parlisten. // Alle tidligere par blir slettet når denne funksjonen kalles. void BroadPhase :: GeneratePairs (void) pairs.clear () // Cache-plass for AABBer som skal brukes i beregning // av hver formens avgrensningsboks AABB A_aabb AABB B_aabb for (i = bodies.begin (); i! = kropper .end (); i = i-> neste) for (j = bodies.begin (); j! = bodies.end (); j = j-> neste) Body * A = & i-> GetData Kropp * B = & j-> GetData () // Hopp sjekke med selv hvis (A == B) fortsette // Bare matchende lag vil bli vurdert hvis (! (A-> lag & B-> lag)) fortsetter; A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) hvis (AABBtoAABB (A_aabb, B_aabb)) par.push_back (A, B)

Layering viser seg å være både svært effektiv og veldig enkel.


Halfspace Intersection

EN halfspace kan ses som en side av en linje i 2D. Oppdage om et punkt er på den ene siden av en linje eller det andre er en ganske vanlig oppgave, og bør forstås grundig av alle som lager sin egen fysikkmotor. Det er så ille at dette emnet egentlig ikke dekkes på internett på en meningsfull måte, i hvert fall fra det jeg har sett - til nå selvfølgelig!

Den generelle ligningen for en linje i 2D er:

\ [Likning 4: \\
Generelt \: skjema: øks + ved + c = 0 \\
Normal \: til \: linje: \ begin bmatrix
en \\\
b \\
\ End bmatrix \]

Merk at, til tross for navnet, er normalvektoren ikke nødvendigvis normalisert (det vil si at den ikke nødvendigvis har en lengde på 1).

For å se om et punkt er på en bestemt side av denne linjen, er alt vi trenger å koble til punktet i x og y variabler i ligningen og kontroller tegn på resultatet. Et resultat av 0 betyr at poenget er på linjen, og positive / negative mener forskjellige sider av linjen.

Det er alt der er til det! Å vite dette er avstanden fra et punkt til linjen faktisk resultatet av den forrige testen. Hvis normalvektoren ikke blir normalisert, blir resultatet skalert av størrelsen på den normale vektoren.


Konklusjon

Nå kan en fullstendig, om enn enkel, fysikkmotor konstrueres helt fra grunnen av. Mer avanserte emner som friksjon, orientering og dynamisk AABB-tre kan dekkes i fremtidige opplæringsprogrammer. Spør spørsmål eller gi kommentarer nedenfor, jeg liker å lese og svare på dem!