I den här delen av min serie om att skapa en anpassad 2D-fysikmotor för dina spel lägger vi till fler funktioner till den impulsupplösning vi arbetade i första delen. I synnerhet tittar vi på integration, tidsbestämning, användning av en modulär design för vår kod och brett fas kollisionsdetektering.
I det sista inlägget i denna serie behandlade jag ämnet impulsupplösning. Läs det först om du inte redan har det!
Låt oss dyka rakt in i de ämnen som omfattas av denna artikel. Dessa ämnen är alla nödvändigheter för någon halvt anständig fysikmotor, så nu är det lämpligt att bygga fler funktioner ovanpå kärnupplösningen från den senaste artikeln.
Integration är helt enkelt att genomföra, och det finns många områden på internet som ger bra information för iterativ integration. Det här avsnittet visar mestadels hur man implementerar en korrekt integrationsfunktion och pekar på några olika platser för vidare läsning, om så önskas.
Först bör det vara känt vilken acceleration som faktiskt är. Newtons andra lag säger:
\ [Ekvation 1: \\
F = ma \]
Detta säger att summan av alla krafter som verkar på något objekt är lika med objektets massa m
multiplicerat med dess acceleration en
. m
är i kilo, en
är i meter / sekund och F
är i newtons.
Omordna denna ekvation lite för att lösa på en
ger:
\ [Ekvation 2: \\
a = \ frac F m \\
\därför\\
a = F * \ frac 1 m \]
Nästa steg innebär att man använder acceleration för att stega ett objekt från en plats till en annan. Eftersom ett spel visas i diskreta separata ramar i en illusionliknande animering, måste placeringarna för varje position vid dessa separata steg beräknas. För en mer djupgående omfattning av dessa ekvationer, se: Erin Cattos Integration Demo från GDC 2009 och Hannus tillägg till symplectic Euler för mer stabilitet i låga FPS-miljöer.
Explicit Euler (uttalad "oiler") integration visas i följande kod, där x
är position och v
är hastighet. Vänligen notera att 1 / m * F
är acceleration, som förklaras ovan:
// Explicit Euler x + = v * dt v + = (1 / m * F) * dt
dt
här hänvisar till delta tid. Δ är symbolen för delta och kan läsas bokstavligen som "förändring", eller skrivas som Δt
. Så när du ser dt
det kan läsas som "förändring i tid". dv
skulle vara "förändring i hastighet". Detta kommer att fungera och används vanligtvis som utgångspunkt. Det har dock numeriska felaktigheter som vi kan bli av med utan extra ansträngning. Här är det som kallas Symplectic Euler:
// Symptisk Euler v + = (1 / m * F) * dt x + = v * dt
Observera att allt jag gjorde var omarrangera ordningen av de två kodkoderna - se "> den ovannämnda artikeln från Hannu.
Det här inlägget förklarar de numeriska felaktigheterna hos Explicit Euler, men varnas att han börjar täcka RK4, som jag inte personligen rekommenderar: gafferongames.com: Euler-felaktighet.
Dessa enkla ekvationer är allt vi behöver för att flytta alla objekt runt med linjär hastighet och acceleration.
Eftersom spel visas på diskreta tidsintervaller måste det vara ett sätt att manipulera tiden mellan dessa steg på ett kontrollerat sätt. Har du någonsin sett ett spel som kommer att köras med olika hastigheter beroende på vilken dator den spelas på? Det är ett exempel på ett spel som körs med en hastighet som är beroende av datorns förmåga att köra spelet.
Vi behöver ett sätt att se till att vår fysikmotor endast körs när en viss tid har gått. På så sätt, dt
som används inom beräkningar är alltid exakt samma nummer. Använda exakt samma dt
Värdet i din kod överallt kommer faktiskt att göra din fysikmotor deterministisk, och är känd som a fast timestep. Det här är en bra sak.
En deterministisk fysikmotor är en som alltid kommer att göra exakt samma sak varje gång den körs förutsatt att samma ingångar ges. Detta är viktigt för många typer av spel där spelningen måste vara mycket finjusterad till fysikmotorns beteende. Detta är också viktigt för att felsöka din fysikmotor, för att kunna identifiera buggar måste motorns beteende vara konsekvent.
Låt oss först täcka en enkel version av ett fast tidsteg. Här är ett exempel:
const float fps = 100 const float dt = 1 / fps float ackumulator = 0 // I enheter av sekunder float frameStart = GetCurrentTime () // huvudslingan medan (sann) const float currentTime = GetCurrentTime () // Spara tiden som har förflutit sedan den sista ramen började ackumulatorn + = currentTime-frameStart () // Spela in starten av denna ramramStart = currentTime medan (ackumulator> dt) UpdatePhysics (dt) ackumulator - = dt RenderGame ()
Detta väntar runt, vilket gör spelet tills tillräckligt med tid har gått för att uppdatera fysiken. Den förflutna tiden är inspelad och diskret dt
-Stora bitar av tid tas från ackumulatorn och bearbetas av fysiken. Detta säkerställer att exakt samma värde överförs till fysiken oavsett vad, och att värdet som övergår till fysiken är en korrekt representation av den aktuella tiden som passerar i det verkliga livet. Bitar av dt
tas bort från ackumulator
tills ackumulator
är mindre än a dt
bit.
Det finns några problem som kan lösas här. Den första innebär hur lång tid det tar att faktiskt utföra fysikuppdateringen: Vad händer om fysikuppdateringen tar för lång tid och ackumulator
går högre och högre varje spelslinga? Detta kallas spiral av döden. Om detta inte är löst, kommer din motor att mala helt och hållet om din fysik inte kan utföras tillräckligt snabbt.
För att lösa detta behöver motorn bara köra färre fysikuppdateringar om ackumulator
blir för hög. Ett enkelt sätt att göra detta skulle vara att klämma fast ackumulator
under några godtyckliga värden.
const float fps = 100 const float dt = 1 / fps float ackumulator = 0 // I enheter sekunder float frameStart = GetCurrentTime () // huvud loop medan (true) const float currentTime = GetCurrentTime () // Spara tiden som har gått sedan sista rammen började ackumulatorn + = currentTime - frameStart () // Spela in början av denna ramramStart = currentTime // Undvik spiral av död och clamp dt, sålunda klämma // hur många gånger UpdatePhysics kan kallas // ett enda spel slinga. om (ackumulator> 0.2f) ackumulator = 0,2f medan (ackumulator> dt) UpdatePhysics (dt) ackumulator - = dt RenderGame ()
Nu, om ett spel som kör denna slinga någonsin möter någon form av stalling av vilken anledning som helst, kommer fysiken inte att drunkna sig i en spiral av döden. Spelet kommer helt enkelt att springa lite långsammare, beroende på vad som är lämpligt.
Nästa sak att fixa är ganska liten i jämförelse med dödens spiral. Den här slingan tar dt
bitar från ackumulator
tills ackumulator
är mindre än dt
. Det här är roligt, men det finns fortfarande en liten bit kvar av tiden kvar i ackumulator
. Detta utgör ett problem.
Antag ackumulator
lämnas med 1/5 av a dt
chunk varje ram. På den sjätte ramen ackumulator
kommer att ha tillräckligt med återstående tid för att utföra en fysikuppdatering än alla andra ramar. Detta resulterar i en ram varje sekund eller så utför ett lite större diskret hopp i tid och kan vara mycket märkbart i ditt spel.
För att lösa detta, användningen av linjär interpolation krävs. Om det här låter läskigt, oroa dig inte - implementeringen kommer att visas. Om du vill förstå genomförandet finns det många resurser online för linjär interpolering.
// linjär interpolering för a från 0 till 1 // från t1 till t2 t1 * a + t2 (1.0f-a)
Med detta kan vi interpolera (ungefärligt) där vi kan vara mellan två olika tidsintervaller. Detta kan användas för att göra tillståndet för ett spel mellan två olika fysikuppdateringar.
Med linjär interpolering kan rendering av en motor springa i en annan takt än fysikmotorn. Detta möjliggör en graciös hantering av kvarvarande ackumulator
från fysikuppdateringarna.
Här är ett fullständigt exempel:
const float fps = 100 const float dt = 1 / fps float ackumulator = 0 // I enheter sekunder float frameStart = GetCurrentTime () // huvud loop medan (true) const float currentTime = GetCurrentTime () // Spara tiden som har gått sedan sista rammen började ackumulatorn + = currentTime - frameStart () // Spela in början av denna ramramStart = currentTime // Undvik spiral av död och clamp dt, sålunda klämma // hur många gånger UpdatePhysics kan kallas // ett enda spel slinga. om (ackumulator> 0.2f) ackumulator = 0,2f medan (ackumulator> dt) UpdatePhysics (dt) ackumulator - = dt const float alfa = ackumulator / dt; RenderGame (alpha) void RenderGame (float alfa) för form i spelet gör // beräkna en interpolerad transform för att göra Transform i = form.previous * alfa + form.current * (1.0f - alfa) form.previous = form.current form .Render (i)
Här kan alla föremål inom spelet dras vid varierande stunder mellan diskreta fysik-tidspunkter. Detta kommer tacksamt hantera allt fel och återstående tid ackumulering. Detta ger faktiskt någonting så lite bakom vad fysiken för närvarande har löst för, men när man tittar på spelkörningen utjämnas all rörelse perfekt genom interpoleringen.
Spelaren kommer aldrig att veta att rendering någonsin är så liten bakom fysiken, eftersom spelaren bara vet vad de ser, och vad de ser är helt smidiga övergångar från en ram till en annan.
Du kanske undrar, "varför interpolerar vi inte från nuvarande position till nästa?". Jag försökte detta och det kräver att man gör "gissa" där objekt kommer att vara i framtiden. Ofta gör föremål i en fysikmotor plötsliga förändringar i rörelse, såsom vid kollision, och när en sådan plötslig rörelseändring görs kommer objekt att teleporteras på grund av felaktiga interpoleringar i framtiden.
Det finns några saker som varje fysikobjekt kommer att behöva. Men de specifika saker som varje fysikobjekt behöver behöver kan ändras något från objekt till objekt. Ett smart sätt att organisera alla dessa data krävs, och det skulle antas att den mindre mängd kod som ska skrivas för att uppnå en sådan organisation är önskvärd. I det här fallet skulle en modulär design vara till nytta.
Modulär design låter förmodligen lite pretentiös eller överkomplicerad, men det är meningsfullt och ganska enkelt. I det här sammanhanget betyder "modulär design" bara att vi vill bryta ett fysikobjekt i separata delar så att vi kan ansluta eller koppla bort dem, men vi ser det som passar.
En fysik kropp är ett objekt som innehåller all information om ett visst fysikobjekt. Det kommer att lagra formen / formerna som objektet representeras av, massdata, transformation (position, rotation), hastighet, vridmoment och så vidare. Här är vad vårt kropp
borde se ut som:
struktur kropp Form * form; Transformera tx; Materialmaterial; MassData mass_data; Vec2 hastighet; Vec2 force; verklig gravitationScale; ;
Detta är en bra utgångspunkt för utformningen av en fysisk kroppsstruktur. Det finns några intelligenta beslut som fattas här som tenderar till stark kodorganisation.
Det första att märka är att en form finns inne i kroppen med hjälp av en pekare. Detta representerar ett löst förhållande mellan kroppen och dess form. En kropp kan innehålla vilken form som helst, och kroppens form kan bytas om efter vilja. Faktum är att en kropp kan representeras av flera former, och en sådan kropp skulle vara känd som en "komposit", eftersom den skulle bestå av flera former. (Jag kommer inte att täcka kompositer i denna handledning.)
Body and Shape-gränssnitt.De form
själv ansvarar för att beräkna avgränsande former, beräkna massa baserat på densitet och återgivning.
De mass_data
är en liten datastruktur som innehåller massrelaterad information:
struct MassData float mass; float inv_mass; // För rotationer (som inte omfattas av denna artikel) flyter tröghet; float inverse_inertia; ;
Det är trevligt att lagra alla mass- och intertionsrelaterade värden i en enda struktur. Massan bör aldrig fastställas för hand - massan bör alltid beräknas med själva formen. Mässan är en ganska ointuitiv typ av värde, och inställning av det för hand kommer att ta mycket tweaking tid. Det definieras som:
\ [Ekvation 3: \\ massa = densitet * volym \]
När en formgivare vill att en form ska vara mer "massiv" eller "tung", bör de ändra densiteten av en form. Denna täthet kan användas för att beräkna massan av en form givet sin volym. Detta är rätt sätt att gå om situationen eftersom täthet inte påverkas av volym och kommer aldrig att förändras under spelets körtid (om inte specifikt stöds med särskild kod).
Några exempel på former som AABB och Circles finns i den tidigare handledningen i denna serie.
Allt detta tal om massa och densitet leder till frågan: Var ligger täthetsvärdet? Det ligger inom Material
strukturera:
struct Material float density; flyterbidrag ;
När materialets värden är inställda kan detta material passera till kroppens form så att kroppen kan beräkna massan.
Det sista som är värt att nämna är gravity_scale
. Skalning av gravitation för olika objekt krävs så ofta för att tweak gameplay att det är bäst att bara inkludera ett värde i varje kropp specifikt för denna uppgift.
Vissa användbara materialinställningar för vanliga materialtyper kan användas för att konstruera en Material
objekt från ett uppräkningsvärde:
Rock Densitet: 0,6 Återstod: 0,1 Trä Densitet: 0,3 Återstod: 0,2 Metaldensitet: 1,2 Återstod: 0,05 BouncyBall Densitet: 0,3 Återstod: 0,8 SuperBall Densitet: 0,3 Återstod: 0,95 Kudddensitet: 0,1 Återstod: 0,2 Statisk densitet: 0,0 Återstod: 0,4
Det finns ytterligare en sak att prata om i kropp
strukturera. Det finns en dataledare som heter tvinga
. Detta värde börjar vid noll i början av varje fysikuppdatering. Andra influenser i fysikmotorn (som gravitation) kommer att lägga till Vec2
vektorer i detta tvinga
data medlem. Strax före integration kommer all denna kraft att användas för att beräkna acceleration av kroppen och användas under integration. Efter integrationen detta tvinga
Datadelementet är nollställt.
Detta möjliggör varje antal krafter att agera på ett objekt närhelst de passar, och ingen extra kod kommer att krävas för att skrivas när nya typer av krafter ska tillämpas på objekt.
Låt oss ta ett exempel. Säg att vi har en liten cirkel som representerar en mycket tung föremål. Den här lilla cirkeln flyger runt i spelet, och det är så tungt att det drar andra föremål mot det någonsin så lite. Här är en viss grov pseudokod för att visa detta:
HeavyObject objekt för kropp i spelet gör om (object.CloseEnoughTo (body) object.ApplyForcePullOn (body)
Funktionen ApplyForcePullOn ()
kan kanske tillämpa en liten kraft för att dra kropp
mot HeavyObject
, endast om kropp
är nära nog.
Det spelar ingen roll hur många krafter som läggs till tvinga
av en kropp, eftersom de alla kommer att lägga till en enda summerad kraftvektor för den kroppen. Detta innebär att två krafter som verkar på samma kropp kan potentiellt avbryta varandra.
I den föregående artikeln i denna serie introducerades kollisionsdetekteringsrutiner. Dessa rutiner var faktiskt åtskilda från det som kallas "smal fas". Skillnaderna mellan bred fas och smal fas kan undersökas ganska lätt med en Google-sökning.
(Kortfattat: vi använder brett fas kollisionsdetektering för att ta reda på vilka par objekt makt kolliderar, och sedan smal fas kollisionsdetektering för att kontrollera om de faktiskt är kolliderar.)
Jag skulle vilja ge några exempelkod tillsammans med en förklaring om hur man implementerar en bred fas av \ (O (n ^ 2) \) tidskomplexitetsparberäkningar.
\ (O (n ^ 2) \) betyder väsentligen att tiden för att kontrollera varje par av potentiella kollisioner beror på kvadraten av antalet objekt. Den använder Big-O-notering.Eftersom vi arbetar med par av objekt, kommer det att vara användbart att skapa en struktur som så:
strukturpar kropp * A; kropp * B; ;
En bred fas bör samla ett gäng möjliga kollisioner och lagra dem alla in Par
strukturer. Dessa par kan sedan vidarebefordras till en annan del av motorn (den smala fasen) och lösas därefter.
Exempel bred fas:
// Genererar parlistan. // Alla tidigare par rensas när den här funktionen heter. void BroadPhase :: GeneratePairs (void) pairs.clear () // Kasseringsutrymme för AABB som ska användas vid beräkning // av varje formens avgränsningslåda AABB A_aabb AABB B_aabb för (i = bodies.begin (); i! = kroppar .end (); i = i-> nästa) för (j = bodies.begin (); j! = bodies.end (); j = j-> nästa) Body * A = & i-> GetData Kropp * B = & j-> GetData () // Hoppa kontroll med själv om (A == B) fortsätt A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) om (AABBtoAABB (A_aabb, B_aabb)) par.push_back (A, B)
Ovanstående kod är ganska enkel: Kontrollera varje kropp mot varje kropp och hoppa över självkontroll.
Det finns ett problem från det sista avsnittet: många dubbla par kommer att returneras! Dessa dubbletter måste avlägsnas från resultaten. Vissa kännedom om sorteringsalgoritmer kommer att krävas här om du inte har någon sorteringsbibliotek tillgänglig. Om du använder C ++ så har du tur:
// Sortera par för att avslöja duplikat sortera (par, pairs.end (), SortPairs); // Queue manifolds för att lösa int i = 0; medan jag < 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) brytning; ++ i;
Efter att ha sorterat alla par i en viss ordning kan det antas att alla par i par
behållaren kommer att ha alla dubbletter intill varandra. Placera alla unika par i en ny behållare som heter uniquePairs
, och jobbet med att dölja dubbletter är klart.
Det sista att nämna är predikatet SortPairs ()
. Detta SortPairs ()
funktionen är vad som faktiskt används för att sortera, och det kan se ut så här:
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;Villkoren
lhs
och rhs
kan läsas som "vänster sida" och "höger sida". Dessa termer används vanligen för att referera till parametrar för funktioner där saker logiskt kan ses som vänster och höger sida av någon ekvation eller algoritm. skiktning hänvisar till handlingen att ha olika objekt aldrig kollidera med varandra. Detta är nyckeln till att kollar som avfyras från vissa föremål inte påverkar vissa andra objekt. Till exempel kan spelare på ett lag vilja att deras raketer skadar fiender men inte varandra.
Layering är bäst implementerad med bitmasks - se en snabb bitmask hur-till för programmerare och Wikipedia-sidan för en snabb introduktion, och filtreringsdelen i Box2D-manualen för att se hur den här motorn använder bitmasker.
Layering ska ske inom den breda fasen. Här klarar jag bara ett färdigt brett fas exempel:
// Genererar parlistan. // Alla tidigare par rensas när den här funktionen heter. void BroadPhase :: GeneratePairs (void) pairs.clear () // Kasseringsutrymme för AABB som ska användas vid beräkning // av varje formens avgränsningslåda AABB A_aabb AABB B_aabb för (i = bodies.begin (); i! = kroppar .end (); i = i-> nästa) för (j = bodies.begin (); j! = bodies.end (); j = j-> nästa) Body * A = & i-> GetData Kropp * B = & j-> GetData () // Hoppa kontroll med själv om (A == B) fortsätt // Endast matchande lager kommer att övervägas om (! (A-> lager & B-> lager)) fortsätt; A-> BeräknaAABB (& A_aabb) B-> BeräknaAABB (& B_aabb) om (AABBtoAABB (A_aabb, B_aabb)) par.push_back (A, B)
Layering visar sig vara både mycket effektiv och mycket enkel.
en halvutrymme kan ses som en sida av en linje i 2D. Upptäcka om en punkt är på ena sidan av en linje eller den andra är en ganska vanlig uppgift, och bör förstås noggrant av alla som skapar sin egen fysikmotor. Det är så illa att det här ämnet verkligen inte täcks överallt på internet på ett meningsfullt sätt, åtminstone från det jag har sett - helt klart nu!
Den allmänna ekvationen för en linje i 2D är:
\ [Ekvation 4: \\
Allmänt \: form: ax + by + c = 0 \\
Normal \: till \: rad: \ begin bmatrix
en \\
b \\
\ End bmatrix \]
Observera att den normala vektorn, trots sitt namn, inte nödvändigtvis normaliseras (det vill säga har den inte nödvändigtvis en längd på 1).
För att se om en punkt är på en viss sida av den här raden, är allt vi behöver göra att ansluta punkten till x
och y
variabler i ekvationen och kontrollera tecknet på resultatet. Ett resultat av 0 betyder att punkten är på linjen och positiva / negativa medelvärden på olika sidor av linjen.
Det är allt det finns! Att veta detta är avståndet från en punkt till linjen faktiskt resultatet av det föregående testet. Om normalvektorn inte normaliseras, kommer resultatet att skalas av storleken på den normala vektorn.
Hittills kan en fullständig, om än enkel fysikmotor byggas helt från början. Mer avancerade ämnen som friktion, orientering och dynamiskt AABB-träd kan omfattas av framtida handledning. Ställ frågor eller lämna kommentarer nedan, jag gillar att läsa och svara på dem!