Come creare un motore di fisica 2D personalizzato attrito, scena e tabella di salto

Nei primi due tutorial di questa serie, ho trattato gli argomenti di Impulse Resolution e Core Architecture. Ora è il momento di aggiungere alcuni dei tocchi finali al nostro motore di fisica basato su impulsi 2D.

Gli argomenti che esamineremo in questo articolo sono:

  • Attrito
  • Scena
  • Collision Jump Table

Consiglio vivamente di leggere i precedenti due articoli della serie prima di tentare di affrontare questo. Alcune informazioni chiave negli articoli precedenti sono state sviluppate all'interno di questo articolo.

Nota: Sebbene questo tutorial sia scritto usando C ++, dovresti essere in grado di utilizzare le stesse tecniche e concetti in quasi tutti gli ambienti di sviluppo di giochi.


Demo video

Ecco una breve demo di ciò a cui stiamo lavorando in questa parte:


Attrito

L'attrito fa parte della risoluzione delle collisioni. L'attrito applica sempre una forza sugli oggetti nella direzione opposta al movimento in cui devono viaggiare.

Nella vita reale, l'attrito è un'interazione incredibilmente complessa tra diverse sostanze, e al fine di modellarlo, vengono fatte grandi ipotesi e approssimazioni. Queste assunzioni sono implicite all'interno della matematica e di solito sono qualcosa come "l'attrito può essere approssimato da un singolo vettore" - analogamente a quanto la dinamica del corpo rigido simula le interazioni della vita reale assumendo corpi con densità uniforme che non può deformare.

Dai una rapida occhiata alla demo video del primo articolo di questa serie:

Le interazioni tra i corpi sono piuttosto interessanti e il rimbalzo durante le collisioni sembra realistico. Tuttavia, una volta che gli oggetti atterrano sulla piattaforma solida, si limitano a premere e allontanarsi dai bordi dello schermo. Ciò è dovuto alla mancanza di simulazione di attrito.

Impulsi, ancora?

Come dovresti ricordare dal primo articolo di questa serie, un valore particolare, j, rappresentava la grandezza di un impulso richiesto per separare la penetrazione di due oggetti durante una collisione. Questa grandezza può essere indicata come jnormal o jN come è usato per modificare la velocità lungo la collisione normale.

Incorporare una risposta di attrito comporta il calcolo di un'altra grandezza, denominata come jtangent o Jt. L'attrito sarà modellato come un impulso. Questa grandezza modificherà la velocità di un oggetto lungo il vettore tangente negativo della collisione, o in altre parole lungo il vettore di attrito. In due dimensioni, la risoluzione di questo vettore di attrito è un problema risolvibile, ma in 3D il problema diventa molto più complesso.

L'attrito è abbastanza semplice e possiamo usare la nostra precedente equazione per j, tranne che sostituiremo tutte le istanze del normale n con un vettore tangente t.

\ [Equazione 1: \\
j = \ frac - (1 + e) ​​(V ^ B -V ^ A) \ cdot n)
\ frac 1 massa ^ A + \ frac 1 massa ^ B \]

Sostituire n con t:

\ [Equazione 2: \\
j = \ frac - (1 + e) ​​((V ^ B -V ^ A) \ cdot t)
\ frac 1 massa ^ A + \ frac 1 massa ^ B \]

Sebbene solo una singola istanza di n è stato sostituito con t in questa equazione, una volta introdotte le rotazioni, alcune altre istanze devono essere sostituite oltre a quella singola nel numeratore dell'equazione 2.

Ora la questione di come calcolare t sorge. Il vettore tangente è un vettore perpendicolare alla collisione normale che è rivolto più verso il normale. Questo potrebbe sembrare confuso - non ti preoccupare, ho un diagramma!

Sotto puoi vedere il vettore tangente perpendicolare al normale. Il vettore tangente può puntare a sinistra oa destra. A sinistra sarebbe "più lontano" dalla velocità relativa. Tuttavia, è definito come la perpendicolare al normale che punta "più verso" la velocità relativa.


Vettori di vario tipo entro i tempi di una collisione di corpi rigidi.

Come affermato brevemente in precedenza, l'attrito sarà un vettore opposto al vettore tangente. Ciò significa che la direzione in cui applicare l'attrito può essere calcolata direttamente, poiché il vettore normale è stato trovato durante il rilevamento delle collisioni.

Sapendo questo, il vettore tangente è (dove n è la collisione normale):

\ [V ^ R = V ^ B -V ^ A \\
t = V ^ R - (V ^ R \ cdot n) * n \]

Tutto ciò che è rimasto da risolvere jt, l'entità dell'attrito, è calcolare il valore direttamente usando le equazioni sopra. Ci sono alcuni pezzi molto difficili dopo che questo valore è stato calcolato che verrà coperto a breve, quindi questa non è l'ultima cosa necessaria nel nostro risolutore di collisione:

 // Ricalcola la velocità relativa dopo l'impulso normale // viene applicato (impulso dal primo articolo, questo codice viene // direttamente successivamente nella stessa funzione di risoluzione) Vec2 rv = VB - VA // Risolve per il vettore tangente Vec2 tangent = rv - Dot (rv, normal) * tangent.Normalize normale () // Risolvi per la magnitudine da applicare lungo il vettore di attrito float jt = -Dot (rv, t) jt = jt / (1 / MassA + 1 / MassB)

Il codice precedente segue direttamente l'equazione 2. Ancora, è importante rendersi conto che il vettore di attrito punta nella direzione opposta del nostro vettore tangente, e come tale dobbiamo applicare un segno negativo quando puntiamo la velocità relativa lungo la tangente per risolvere la velocità relativa lungo il vettore tangente. Questo segno negativo capovolge la velocità tangente e punta improvvisamente nella direzione in cui l'attrito dovrebbe essere approssimato come.

Legge di Coulomb

La legge di Coulomb è la parte della simulazione di attrito che la maggior parte dei programmatori ha problemi con. Io stesso dovevo fare un bel po 'di studi per capire il modo corretto di modellarlo. Il trucco è che la legge di Coulomb è una disuguaglianza.

Stati di attrito di Coulomb:

\ [Equazione 3: \\
F_f <= \mu F_n \]

In altre parole, la forza di attrito è sempre inferiore o uguale alla forza normale moltiplicata per qualche costante μ (il cui valore dipende dai materiali degli oggetti).

La forza normale è solo la nostra vecchia j grandezza moltiplicata per la collisione normale. Quindi se il nostro risolto jt (che rappresenta la forza di attrito) è inferiore a μ volte la forza normale, quindi possiamo usare il nostro jt grandezza come attrito. Se no, allora dobbiamo usare i nostri normali tempi di forza μ anziché. Questo caso "altro" è una forma di blocco del nostro attrito al di sotto di un valore massimo, essendo il massimo i normali tempi di forza μ.

L'intero punto della legge di Coulomb è di eseguire questa procedura di serraggio. Questo serraggio risulta essere la parte più difficile della simulazione dell'attrito per la risoluzione basata sugli impulsi per trovare la documentazione ovunque, almeno fino ad ora! La maggior parte dei white paper che ho trovato sul soggetto hanno saltato del tutto l'attrito, o si sono fermati e hanno implementato procedure di bloccaggio improprie (o inesistenti). Spero che ora abbiate un apprezzamento per la comprensione del fatto che ottenere questa parte è importante.

Mettiamo da parte il serraggio tutto in una volta prima di spiegare qualsiasi cosa. Il prossimo blocco di codice è il precedente esempio di codice con la procedura di serraggio finita e l'applicazione di impulso di attrito tutti insieme:

 // Ricalcola la velocità relativa dopo l'impulso normale // viene applicato (impulso dal primo articolo, questo codice viene // direttamente successivamente nella stessa funzione di risoluzione) Vec2 rv = VB - VA // Risolve per il vettore tangente Vec2 tangent = rv - Dot (rv, normal) * tangent normale.Normalize () // Risolvi per grandezza da applicare lungo il vettore di attrito float jt = -Dot (rv, t) jt = jt / (1 / MassA + 1 / MassB) // PythagoreanSolve = A ^ 2 + B ^ 2 = C ^ 2, risolvendo per C dato A e B // Da usare per approssimare i coefficienti di attrito mu forniti da ciascun corpo float mu = PitagoraSolve (A-> staticFriction, B-> staticFriction) // Clamp magnitudo di attrito e creare impulso vettoriale Vec2 frictionImpulse if (abs (jt) < j * mu) frictionImpulse = jt * t else  dynamicFriction = PythagoreanSolve( A->dynamicFriction, B-> dynamicFriction) frictionImpulse = -j * t * dynamicFriction // Applica A-> velocity - = (1 / A-> massa) * frictionImpulse B-> velocity + = (1 / B-> massa) * frictionImpulse

Ho deciso di utilizzare questa formula per risolvere i coefficienti di attrito tra due corpi, dato un coefficiente per ciascun corpo:

\ [Equazione 4: \\
Attrito = \ sqrt [] Friction ^ 2_A + Friction ^ 2_B \]

In realtà ho visto qualcun altro farlo nel proprio motore fisico, e il risultato mi è piaciuto. Una media dei due valori funzionerebbe perfettamente per eliminare l'uso della radice quadrata. In realtà, qualsiasi forma di scelta del coefficiente di attrito funzionerà; questo è proprio quello che preferisco. Un'altra opzione potrebbe essere quella di utilizzare una tabella di ricerca in cui il tipo di ciascun corpo viene utilizzato come indice in una tabella 2D.

È importante che il valore assoluto di jt è usato nel confronto, poiché il confronto sta teoricamente bloccando le grandezze primarie sotto qualche soglia. Da j è sempre positivo, deve essere capovolto per rappresentare un vettore di attrito adeguato, nel caso si utilizzi l'attrito dinamico.

Attrito statico e dinamico

Nell'ultimo frammento di codice sono stati introdotti attriti statici e dinamici senza alcuna spiegazione! Dedico tutta questa sezione per spiegare la differenza tra e la necessità di questi due tipi di valori.

Qualcosa di interessante accade con l'attrito: richiede una "energia di attivazione" affinché gli oggetti inizino a muoversi quando sono completamente riposati. Quando due oggetti si appoggiano l'uno sull'altro nella vita reale, ci vuole una buona quantità di energia per spingerne uno e farlo muovere. Tuttavia, una volta ottenuto uno scivolamento, è spesso più facile farlo scorrere da quel momento in poi.

Ciò è dovuto al modo in cui l'attrito funziona a livello microscopico. Un'altra immagine aiuta qui:


Vista microscopica di ciò che causa energia di attivazione a causa dell'attrito.

Come puoi vedere, le piccole deformità tra le superfici sono in realtà il principale colpevole che crea attrito in primo luogo. Quando un oggetto è a riposo su un altro, deformazioni microscopiche riposano tra gli oggetti, ad incastro. Questi devono essere spezzati o separati in modo che gli oggetti possano scivolare l'uno contro l'altro.

Abbiamo bisogno di un modo per modellarlo all'interno del nostro motore. Una soluzione semplice consiste nel fornire ogni tipo di materiale con due valori di attrito: uno per la statica e uno per la dinamica.

L'attrito statico è usato per bloccare il nostro jt grandezza. Se risolto jt la magnitudine è sufficientemente bassa (sotto la nostra soglia), quindi possiamo assumere che l'oggetto sia a riposo, o quasi che riposi e utilizzi l'intero jt come un impulso.

Sul rovescio della medaglia, se il nostro risolto jt è sopra la soglia, si può presumere che l'oggetto abbia già rotto l'"energia di attivazione", e in tale situazione viene utilizzato un impulso di attrito inferiore, che è rappresentato da un coefficiente di attrito inferiore e un calcolo di impulso leggermente diverso.


Scena

Supponendo che tu non abbia saltato nessuna parte della sezione di attrito, ben fatto! Hai completato la parte più difficile di questa intera serie (secondo me).

Il Scena la classe funge da contenitore per tutto ciò che comporta uno scenario di simulazione fisica. Chiama e utilizza i risultati di qualsiasi fase generale, contiene tutti i corpi rigidi, esegue controlli di collisione e chiama la risoluzione. Inoltre, integra tutti gli oggetti dal vivo. La scena si interfaccia anche con l'utente (come nel programmatore che usa il motore fisico).

Ecco un esempio di come può essere una struttura di scena:

 class Scene public: Scene (gravità Vec2, dt reale); ~ Scene (); void SetGravity (Vec2 gravity) void SetDT (real dt) Body * CreateBody (ShapeInterface * shape, BodyDef def) // Inserisce un corpo nella scena e inizializza il corpo (calcola la massa). // void InsertBody (Body * body) // Elimina un corpo dalla scena void RemoveBody (Body * body) // Aggiorna la scena con un singolo vuoto timestep Step (void) float GetDT (void) LinkedList * GetBodyList (void) Vec2 GetGravity (void) void QueryAABB (CallBackQuery cb, const AABB & aabb) void QueryPoint (CallBackQuery cb, const Point2 & point) private: float dt // Timestep in secondi float inv_dt // Inverso timestep in sceonds LinkedList body_list uint32 body_count Vec2 gravity bool debug_draw BroadPhase broadphase;

Non c'è nulla di particolarmente complesso nel Scena classe. L'idea è di consentire all'utente di aggiungere e rimuovere facilmente corpi rigidi. Il BodyDef è una struttura che contiene tutte le informazioni su un corpo rigido e può essere utilizzata per consentire all'utente di inserire valori come una sorta di struttura di configurazione.

L'altra importante funzione è Passo(). Questa funzione esegue un singolo giro di controlli di collisione, risoluzione e integrazione. Questo dovrebbe essere chiamato dall'interno del ciclo di timestamp delineato nel secondo articolo di questa serie.

Interrogare un punto o AABB implica il controllo per vedere quali oggetti effettivamente si scontrano con un puntatore o AABB all'interno della scena. Ciò rende più semplice la logica relativa al gameplay per vedere come vengono posizionate le cose nel mondo.


Salta Tabella

Abbiamo bisogno di un modo semplice per selezionare quale funzione di collisione deve essere chiamata, in base al tipo di due oggetti diversi.

In C ++ ci sono due modi principali di cui sono a conoscenza: doppio dispatch e una tabella di salto 2D. Nei miei test personali ho trovato il tavolo di salto 2D superiore, quindi entrerò nei dettagli su come implementarlo. Se stai pianificando di usare una lingua diversa da C o C ++ sono sicuro che una serie di funzioni o oggetti functor possano essere costruiti in modo simile a una tabella di puntatori di funzione (che è un'altra ragione per cui ho scelto di parlare di tabelle di salto piuttosto che di altre opzioni che sono più specifici di C ++).

Una tabella di salto in C o C ++ è una tabella di puntatori di funzione. Indici che rappresentano nomi o costanti arbitrari vengono utilizzati per indicizzare nella tabella e chiamare una funzione specifica. L'utilizzo potrebbe essere simile a questo per un jump table 1D:

 enum Animal Rabbit Duck Lion; const void (* talk) (void) [] = RabbitTalk, DuckTalk, LionTalk,; // Chiama una funzione dalla tabella con un messaggio di dispatch virtuale 1D [Rabbit] () // chiama la funzione RabbitTalk

Il codice sopra in realtà imita ciò che il linguaggio C ++ stesso implementa chiamate di funzione virtuali ed ereditarietà. Tuttavia, C ++ implementa solo chiamate virtuali monodimensionali. Un tavolo 2D può essere costruito a mano.

Ecco alcuni psuedocode per una tabella di salto 2D per chiamare le routine di collisione:

 collisionCallbackArray = AABBvsAABB AABBvsCircle CirclevsAABB CirclevsCircle // Chiama una routine di collsion per il rilevamento delle collisioni tra A e B // due collisori senza conoscere il tipo esatto del collector // tipo può essere di AABB o Circle collisionCallbackArray [A-> tipo] [B -> tipo] (A, B)

E lì ce l'abbiamo! I tipi effettivi di ciascun collisore possono essere utilizzati per indicizzare in una matrice 2D e selezionare una funzione per risolvere la collisione.

Nota, tuttavia, quello AABBvsCircle e CirclevsAABB sono quasi duplicati. Questo è necessario! Il normale deve essere capovolto per una di queste due funzioni, e questa è l'unica differenza tra loro. Ciò consente una risoluzione di collisione coerente, indipendentemente dalla combinazione di oggetti da risolvere.


Conclusione

Ormai abbiamo coperto una grande quantità di argomenti nella creazione di un motore di fisica del corpo rigido personalizzato completamente da zero! Risoluzione delle collisioni, attrito e architettura del motore sono tutti gli argomenti trattati finora. Un motore fisico di successo completo adatto a molti giochi bidimensionali a livello di produzione può essere costruito con le conoscenze presentate finora in questa serie.

Guardando al futuro, ho intenzione di scrivere un altro articolo dedicato interamente a una caratteristica molto desiderabile: rotazione e orientamento. Gli oggetti orientati sono estremamente attraenti per guardare interagire tra loro e sono il pezzo finale che richiede il nostro motore fisico personalizzato.

La risoluzione della rotazione risulta essere piuttosto semplice, sebbene il rilevamento delle collisioni abbia un impatto in termini di complessità. Buona fortuna fino alla prossima volta, e per favore fai domande o posta commenti qui sotto!