In questa parte della mia serie sulla creazione di un motore fisico 2D personalizzato per i tuoi giochi, aggiungeremo altre funzionalità alla risoluzione dell'impulso che abbiamo lavorato nella prima parte. In particolare, guarderemo all'integrazione, al timestepping, usando un design modulare per il nostro codice e un'ampia rilevazione delle collisioni di fase.
Nell'ultimo post di questa serie ho trattato l'argomento della risoluzione degli impulsi. Leggilo prima, se non l'hai già fatto!
Entriamo direttamente negli argomenti trattati in questo articolo. Questi argomenti sono tutte le necessità di un motore fisico semi-decente, quindi ora è il momento giusto per costruire più funzionalità in cima alla risoluzione principale dell'ultimo articolo.
L'integrazione è completamente semplice da implementare e ci sono molte aree su Internet che forniscono buone informazioni per l'integrazione iterativa. Questa sezione mostrerà principalmente come implementare una corretta funzione di integrazione e, se lo si desidera, indicherà alcune posizioni diverse per ulteriori letture.
Per prima cosa dovrebbe essere noto quale sia effettivamente l'accelerazione. La seconda legge di Newton afferma:
\ [Equazione 1: \\
F = ma \]
Questo afferma che la somma di tutte le forze che agiscono su un oggetto è uguale alla massa dell'oggetto m
moltiplicato per la sua accelerazione un
. m
è in chilogrammi, un
è in metri al secondo, e F
è in Newton.
Riorganizzare questa equazione un po 'per risolvere un
rendimenti:
\ [Equazione 2: \\
a = \ frac F m \\
\perciò\\
a = F * \ frac 1 m \]
Il prossimo passo prevede l'utilizzo dell'accelerazione per passare un oggetto da una posizione all'altra. Poiché un gioco viene visualizzato in frame separati separati in un'animazione simile all'illusione, è necessario calcolare le posizioni di ciascuna posizione in questi passaggi discreti. Per una copertura più approfondita di queste equazioni, vedere: Demo dell'integrazione di Erin Catto di GDC 2009 e l'aggiunta di Hannu a Eulero simplettico per una maggiore stabilità in ambienti con bassi FPS.
L'integrazione esplicita di Eulero (pronuncia "oliatore") è mostrata nel seguente frammento, dove X
è la posizione e v
è velocità. Si prega di notare che 1 / m * F
è l'accelerazione, come spiegato sopra:
// Eulero esplicito x + = v * dt v + = (1 / m * F) * dt
dt
qui si riferisce al tempo delta. Δ è il simbolo di delta e può essere letto letteralmente come "change in" o scritto come Δt
. Quindi ogni volta che vedi dt
può essere letto come "modifica nel tempo". dv
sarebbe "cambio di velocità". Questo funzionerà, ed è comunemente usato come punto di partenza. Tuttavia, ha delle imprecisioni numeriche che possiamo eliminare senza ulteriori sforzi. Ecco ciò che è noto come Eulero simplettico:
// Symplectic Euler v + = (1 / m * F) * dt x + = v * dt
Nota che tutto ciò che ho fatto è stato riorganizzare l'ordine delle due linee di codice - vedi "> l'articolo di Hannu sopra citato.
Questo post spiega le inesattezze numeriche di Explicit Eulero, ma ti avverto che inizia a coprire l'RK4, che personalmente non raccomando: gafferongames.com: Euler Inaccuracy.
Queste semplici equazioni sono tutto ciò di cui abbiamo bisogno per spostare tutti gli oggetti con velocità e accelerazione lineari.
Poiché i giochi sono visualizzati a intervalli di tempo discreti, deve esserci un modo di manipolare il tempo tra questi passaggi in modo controllato. Hai mai visto un gioco che girerà a velocità diverse a seconda del computer su cui viene riprodotto? Questo è un esempio di un gioco in esecuzione a una velocità dipendente dalla capacità del computer di eseguire il gioco.
Abbiamo bisogno di un modo per garantire che il nostro motore fisico funzioni solo quando è trascorso un determinato periodo di tempo. In questo modo, il dt
che viene utilizzato nei calcoli è sempre lo stesso numero esatto. Usando lo stesso identico dt
il valore nel tuo codice ovunque farà effettivamente il tuo motore fisico deterministico, ed è noto come a timestep fisso. Questa è una buona cosa.
Un motore fisico deterministico è quello che farà sempre la stessa identica cosa ogni volta che viene eseguito assumendo gli stessi input dati. Questo è essenziale per molti tipi di giochi in cui il gameplay deve essere ottimizzato per il comportamento del motore fisico. Questo è essenziale anche per il debug del tuo motore fisico, in quanto per individuare i bug il comportamento del tuo motore deve essere coerente.
Per prima cosa copriamo una versione semplice di un timestep fisso. Ecco un esempio:
const float fps = 100 const float dt = 1 / fps float accumulator = 0 // In unità di secondi float frameStart = GetCurrentTime () // ciclo principale while (true) const float currentTime = GetCurrentTime () // Memorizza il tempo trascorso da l'ultimo frame ha iniziato all'accumulatore + = currentTime - frameStart () // Registra l'inizio di questo frame frameStart = currentTime while (accumulator> dt) UpdatePhysics (dt) accumulator - = dt RenderGame ()
Ciò attende, rendendo il gioco, fino a quando è trascorso abbastanza tempo per aggiornare la fisica. Il tempo trascorso è registrato e discreto dt
-pezzi di tempo sono prelevati dall'accumulatore e processati dalla fisica. Ciò garantisce che lo stesso identico valore sia trasmesso alla fisica indipendentemente da cosa, e che il valore trasmesso alla fisica sia una rappresentazione accurata del tempo reale che passa nella vita reale. Pezzi di dt
vengono rimossi dal accumulatore
fino al accumulatore
è più piccolo di a dt
pezzo.
Ci sono un paio di problemi che possono essere risolti qui. Il primo implica quanto tempo ci vuole per eseguire effettivamente l'aggiornamento della fisica: cosa succede se l'aggiornamento della fisica richiede troppo tempo e il accumulatore
va sempre più in alto ogni ciclo di gioco? Questo è chiamato la spirale della morte. Se questo non è corretto, il tuo motore si fermerà rapidamente se la tua fisica non può essere eseguita abbastanza velocemente.
Per risolvere questo, il motore ha davvero bisogno di eseguire meno aggiornamenti di fisica se il accumulatore
diventa troppo alto Un modo semplice per farlo sarebbe quello di bloccare il accumulatore
sotto qualche valore arbitrario.
const float fps = 100 const float dt = 1 / fps float accumulator = 0 // In unità secondi float frameStart = GetCurrentTime () // ciclo principale while (true) const float currentTime = GetCurrentTime () // Memorizza il tempo trascorso dal ultimo frame ha iniziato l'accumulatore + = currentTime - frameStart () // Registra l'inizio di questo frame frameStart = currentTime // Evita la spirale di morte e clamp dt, quindi serrando // quante volte l'UpdatePhysics può essere chiamato in // un singolo gioco ciclo continuo. se (accumulator> 0.2f) accumulator = 0.2f while (accumulator> dt) UpdatePhysics (dt) accumulator - = dt RenderGame ()
Ora, se un gioco che esegue questo ciclo incontra una sorta di stallo per qualsiasi motivo, la fisica non si annegherà in una spirale di morte. Il gioco funzionerà semplicemente un po 'più lentamente, a seconda dei casi.
La prossima cosa da sistemare è abbastanza piccola rispetto alla spirale della morte. Questo ciclo sta prendendo dt
pezzi dal accumulatore
fino al accumulatore
è più piccolo di dt
. Questo è divertente, ma c'è ancora un po 'di tempo rimasto nel accumulatore
. Questo pone un problema.
Presuma il accumulatore
è lasciato con 1/5 di a dt
pezzo ogni fotogramma. Al sesto fotogramma il accumulatore
avrà abbastanza tempo rimanente per eseguire un aggiornamento di fisica in più rispetto a tutti gli altri fotogrammi. Ciò si tradurrà in un fotogramma al secondo o in un secondo momento eseguendo un salto discreto leggermente più grande nel tempo, e potrebbe essere molto evidente nel tuo gioco.
Per risolvere questo, l'uso di interpolazione lineare è obbligatorio. Se questo sembra spaventoso, non preoccuparti: verrà mostrata l'implementazione. Se vuoi capire l'implementazione ci sono molte risorse online per l'interpolazione lineare.
// interpolazione lineare per a da 0 a 1 // da t1 a t2 t1 * a + t2 (1.0f - a)
Usando questo possiamo interpolare (approssimativamente) dove potremmo essere tra due diversi intervalli di tempo. Questo può essere usato per rendere lo stato di un gioco tra due diversi aggiornamenti di fisica.
Con l'interpolazione lineare, il rendering di un motore può funzionare a un ritmo diverso rispetto al motore fisico. Ciò consente una gestione elegante degli avanzi accumulatore
dagli aggiornamenti di fisica.
Ecco un esempio completo:
const float fps = 100 const float dt = 1 / fps float accumulator = 0 // In unità secondi float frameStart = GetCurrentTime () // ciclo principale while (true) const float currentTime = GetCurrentTime () // Memorizza il tempo trascorso dal ultimo frame ha iniziato l'accumulatore + = currentTime - frameStart () // Registra l'inizio di questo frame frameStart = currentTime // Evita la spirale di morte e clamp dt, quindi serrando // quante volte l'UpdatePhysics può essere chiamato in // un singolo gioco ciclo continuo. se (accumulator> 0.2f) accumulator = 0.2f while (accumulator> dt) UpdatePhysics (dt) accumulator - = dt const float alpha = accumulator / dt; RenderGame (alpha) void RenderGame (float alpha) per la forma nel gioco do // calcola una trasformazione interpolata per il rendering Transform i = shape.previous * alpha + shape.current * (1.0f - alpha) shape.previous = shape.current shape .Render (i)
Qui, tutti gli oggetti all'interno del gioco possono essere disegnati in momenti variabili tra timestep fisici discreti. Questo gestirà con grazia tutti gli errori e l'accumulo di tempo residuo. Questo è in realtà il rendering sempre leggermente indietro rispetto a ciò che la fisica ha attualmente risolto, ma quando si guarda il gioco, tutto il movimento viene attenuato perfettamente dall'interpolazione.
Il giocatore non saprà mai che il rendering è sempre leggermente indietro rispetto alla fisica, perché il giocatore saprà solo quello che vede, e ciò che vedranno è transizioni perfettamente fluide da un fotogramma all'altro.
Forse ti starai chiedendo, "perché non interpoliamo dalla posizione attuale a quella successiva?". Ho provato questo e richiede il rendering per "indovinare" dove gli oggetti saranno in futuro. Spesso, gli oggetti in un motore fisico compiono cambiamenti improvvisi nei movimenti, come durante la collisione, e quando viene effettuato un tale movimento improvviso, gli oggetti si teletrasportano a causa di interpolazioni imprecise nel futuro.
Ci sono alcune cose che ogni oggetto di fisica avrà bisogno. Tuttavia, le cose specifiche di cui ogni oggetto fisico ha bisogno possono cambiare leggermente da oggetto a oggetto. È necessario un modo intelligente per organizzare tutti questi dati e si presuppone che sia richiesta la minore quantità di codice da scrivere per ottenere tale organizzazione. In questo caso, un buon design modulare sarebbe utile.
Il design modulare probabilmente sembra un po 'pretenzioso o troppo complicato, ma ha senso ed è piuttosto semplice. In questo contesto, "design modulare" significa semplicemente che vogliamo rompere un oggetto fisico in parti separate, in modo che possiamo collegarci o disconnetterli, come invece riteniamo opportuno.
Un corpo fisico è un oggetto che contiene tutte le informazioni su un determinato oggetto fisico. Memorizzerà la forma (s) che l'oggetto è rappresentato da, dati di massa, trasformazione (posizione, rotazione), velocità, coppia e così via. Ecco cosa è il nostro corpo
dovrebbe apparire come:
struct body Shape * shape; Trasforma tx; Materiale materiale; MassData mass_data; Velocità Vec2; Forza Vec2; vero gravityScale; ;
Questo è un ottimo punto di partenza per la progettazione di una struttura corporea fisica. Ci sono alcune decisioni intelligenti fatte qui che tendono alla forte organizzazione del codice.
La prima cosa da notare è che una forma è contenuta all'interno del corpo per mezzo di un puntatore. Questo rappresenta una relazione allentata tra il corpo e la sua forma. Un corpo può contenere qualsiasi forma e la forma di un corpo può essere scambiata a volontà. In effetti, un corpo può essere rappresentato da più forme, e un tale corpo sarebbe conosciuto come un "composito", in quanto sarebbe composto da più forme. (Non tratterò i compositi in questo tutorial.)
Interfaccia corpo e forma.Il forma
esso stesso è responsabile per il calcolo di forme delimitanti, calcolo della massa in base alla densità e al rendering.
Il mass_data
è una piccola struttura dati per contenere informazioni relative alla massa:
struct MassData massa fluttuante; float inv_mass; // Per le rotazioni (non trattate in questo articolo) fluttuare l'inerzia; float inverse_inertia; ;
È bello memorizzare tutti i valori relativi alla massa e all'intertia in un'unica struttura. La massa non dovrebbe mai essere impostata a mano - la massa dovrebbe sempre essere calcolata dalla forma stessa. La messa è un tipo di valore piuttosto non intuitivo, e impostarlo a mano richiederà molto tempo. È definito come:
\ [Equazione 3: \\ Massa = densità * volume \]
Ogni volta che un designer vuole che una forma sia più "massiccia" o "pesante", dovrebbe modificare la densità di una forma. Questa densità può essere utilizzata per calcolare la massa di una forma in base al volume. Questo è il modo corretto per affrontare la situazione, poiché la densità non è influenzata dal volume e non cambierà mai durante il runtime del gioco (a meno che non sia supportato specificamente con un codice speciale).
Alcuni esempi di forme come AABB e Cerchi sono disponibili nel precedente tutorial di questa serie.
Tutto questo parlare di massa e densità porta alla domanda: dove si trova il valore di densità? Risiede all'interno del Materiale
struttura:
struct Material float density; restituzione fluttuante; ;
Una volta impostati i valori del materiale, questo materiale può essere passato alla forma di un corpo in modo che il corpo possa calcolare la massa.
L'ultima cosa degna di menzione è il gravity_scale
. Scalare la gravità per oggetti diversi è così spesso richiesto per modificare il gameplay che è meglio includere solo un valore in ogni corpo specifico per questa attività.
Alcune impostazioni materiali utili per tipi di materiali comuni possono essere utilizzate per costruire a Materiale
oggetto da un valore di enumerazione:
Densità della roccia: 0.6 Restituzione: 0.1 Densità del legno: 0.3 Restituzione: 0.2 Densità del metallo: 1.2 Restituzione: 0.05 BouncyBall Densità: 0.3 Restituzione: 0.8 SuperBall Densità: 0.3 Restituzione: 0.95 Densità cuscino: 0.1 Restituzione: 0.2 Densità statica: 0.0 Restituzione: 0.4
C'è un'altra cosa di cui parlare nel corpo
struttura. C'è un membro dei dati chiamato vigore
. Questo valore inizia da zero all'inizio di ogni aggiornamento di fisica. Altre influenze nel motore fisico (come la gravità) aggiungeranno vec2
vettori in questo vigore
membro dei dati. Poco prima dell'integrazione, tutta questa forza verrà utilizzata per calcolare l'accelerazione del corpo e sarà utilizzata durante l'integrazione. Dopo l'integrazione questo vigore
membro dati è azzerato.
Ciò consente a qualsiasi numero di forze di agire su un oggetto ogni volta che lo ritengono opportuno e non è necessario scrivere alcun codice aggiuntivo quando nuovi tipi di forze devono essere applicati agli oggetti.
Prendiamo un esempio. Diciamo che abbiamo un piccolo cerchio che rappresenta un oggetto molto pesante. Questo piccolo cerchio sta volando nel gioco, ed è così pesante da trascinare altri oggetti verso di esso. Ecco qualche pseudocodice approssimativo per dimostrarlo:
Oggetto HeavyObject per body in game do if (object.CloseEnoughTo (body) object.ApplyForcePullOn (body)
La funzione ApplyForcePullOn ()
potrebbe forse applicare una piccola forza per tirare il corpo
verso il HeavyObject
, solo se il corpo
è abbastanza vicino.
Non importa quante forze vengono aggiunte al vigore
di un corpo, poiché tutti sommeranno un singolo vettore di forza sommato per quel corpo. Ciò significa che due forze che agiscono sullo stesso corpo possono potenzialmente annullarsi a vicenda.
Nel precedente articolo di questa serie sono state introdotte routine di rilevamento delle collisioni. Queste routine erano in realtà diverse da quella che è nota come la "fase ristretta". Le differenze tra fase ampia e fase stretta possono essere ricercate piuttosto facilmente con una ricerca su Google.
(In breve: usiamo il rilevamento delle collisioni di fase ampia per capire quali coppie di oggetti potrebbe essere in collisione e quindi rilevare la collisione in fase ristretta per verificare se effettivamente siamo collisione.)
Vorrei fornire un codice di esempio insieme a una spiegazione su come implementare una vasta fase di calcoli della coppia tempo-complessità \ (O (n ^ 2) \).
\ (O (n ^ 2) \) significa essenzialmente che il tempo impiegato per controllare ogni coppia di potenziali collisioni dipenderà dal quadrato del numero di oggetti. Usa notazione Big-O.Dato che lavoriamo con coppie di oggetti, sarà utile creare una struttura in questo modo:
struct Pair body * A; corpo * B; ;
Una fase ampia dovrebbe raccogliere un sacco di possibili collisioni e archiviarle tutte Paio
strutture. Queste coppie possono quindi essere trasferite su un'altra parte del motore (la fase stretta) e quindi risolte.
Esempio di fase generale:
// Genera la lista delle coppie. // Tutte le coppie precedenti vengono cancellate quando viene chiamata questa funzione. void BroadPhase :: GeneratePairs (void) pairs.clear () // Spazio della cache per gli AABB da utilizzare nel calcolo // del riquadro di delimitazione di ogni forma AABB A_aabb AABB B_aabb for (i = bodies.begin (); i! = corpi .end (); i = i-> next) for (j = bodies.begin (); j! = bodies.end (); j = j-> next) Body * A = & i-> GetData () Corpo * B = & j-> GetData () // Ignora controllo con sé se (A == B) continua A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) if (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back (A, B)
Il codice sopra è abbastanza semplice: controlla ogni corpo contro ogni corpo e salta i controlli automatici.
C'è un problema nell'ultima sezione: verranno restituite molte coppie duplicate! Questi duplicati devono essere selezionati dai risultati. Una certa familiarità con gli algoritmi di ordinamento sarà necessaria qui se non si dispone di una sorta di libreria di ordinamento disponibile. Se stai usando C ++, sei fortunato:
// Ordina coppie per esporre ordinamenti duplicati (coppie, pairs.end (), SortPairs); // Queue varietà per risolvere int i = 0; mentre io < 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 || pair-> B! = potential_dup-> A) break; ++ i;
Dopo aver ordinato tutte le coppie in un ordine specifico, si può presumere che tutte le coppie in coppie
contenitore avrà tutti i duplicati adiacenti l'uno all'altro. Metti tutte le coppie uniche in un nuovo contenitore chiamato uniquePairs
, e il lavoro di abbattimento dei duplicati è finito.
L'ultima cosa da menzionare è il predicato SortPairs ()
. Questo SortPairs ()
la funzione è ciò che è effettivamente usato per fare l'ordinamento, e potrebbe assomigliare a questo:
bool SortPairs (coppia lhs, coppia rhs) if (lhs.A < rhs.A) return true; if(lhs.A == rhs.A) return lhs.B < rhs.B; return false;I termini
LHS
e RHS
può essere letto come "lato sinistro" e "lato destro". Questi termini sono comunemente usati per riferirsi a parametri di funzioni in cui le cose possono essere viste logicamente come il lato sinistro e destro di alcune equazioni o algoritmi. stratificazione si riferisce all'atto di avere oggetti diversi che non entrano mai in collisione tra loro. Questa è la chiave per fare in modo che i proiettili sparati da determinati oggetti non influiscano su certi altri oggetti. Ad esempio, i giocatori di una squadra potrebbero volere che i loro razzi danneggiano i nemici, ma non l'uno con l'altro.
La stratificazione è meglio implementata con bitmasks - vedere una procedura rapida per la maschera di bit per i programmatori e la pagina di Wikipedia per una rapida introduzione e la sezione Filtro del manuale di Box2D per vedere come quel motore utilizza le maschere di bit.
La stratificazione dovrebbe essere fatta all'interno della fase generale. Qui inserisco semplicemente un esempio di fase ampia finita:
// Genera la lista delle coppie. // Tutte le coppie precedenti vengono cancellate quando viene chiamata questa funzione. void BroadPhase :: GeneratePairs (void) pairs.clear () // Spazio della cache per gli AABB da utilizzare nel calcolo // del riquadro di delimitazione di ogni forma AABB A_aabb AABB B_aabb for (i = bodies.begin (); i! = corpi .end (); i = i-> next) for (j = bodies.begin (); j! = bodies.end (); j = j-> next) Body * A = & i-> GetData () Corpo * B = & j-> GetData () // Ignora controllo con self se (A == B) continua // Solo i layer corrispondenti saranno considerati se (! (A-> layers & B-> layers)) continuano; A-> ComputeAABB (& A_aabb) B-> ComputeAABB (& B_aabb) if (AABBtoAABB (A_aabb, B_aabb)) pairs.push_back (A, B)
La stratificazione risulta essere altamente efficiente e molto semplice.
UN semispazio può essere visto come un lato di una linea in 2D. Rilevare se un punto si trova su un lato di una linea o l'altro è un compito abbastanza comune e deve essere compreso a fondo da chiunque crei il proprio motore fisico. Peccato che questo argomento non sia realmente coperto da nessuna parte su Internet in modo significativo, almeno da quello che ho visto - fino ad ora, naturalmente!
L'equazione generale di una linea in 2D è:
\ [Equazione 4: \\
Generale \: forma: ax + di + c = 0 \\
Normal \: a \: line: \ begin bmatrix
a \\
b \\
\ End bmatrix \]
Nota che, nonostante il suo nome, il vettore normale non è necessariamente normalizzato (cioè, non ha necessariamente una lunghezza di 1).
Per vedere se un punto si trova su un lato particolare di questa linea, tutto ciò che dobbiamo fare è inserire il punto nel X
e y
variabili nell'equazione e controllare il segno del risultato. Un risultato di 0 significa che il punto è sulla linea e positivo / negativo indicano i diversi lati della linea.
Questo è tutto ciò che c'è da fare! Sapendo che la distanza da un punto alla linea è in realtà il risultato del test precedente. Se il vettore normale non è normalizzato, il risultato verrà ridimensionato in base alla grandezza del vettore normale.
Ormai un motore fisico completo, anche se semplice, può essere costruito interamente da zero. Argomenti più avanzati come l'attrito, l'orientamento e l'albero dinamico AABB possono essere trattati in future esercitazioni. Si prega di fare domande o fornire commenti di seguito, mi piace leggere e rispondere a loro!