Stellen Sie sich das Problem vor: Sie haben ein Spiel und es muss mit 60 fps auf einem 60-Hz-Monitor funktionieren. Ihr Computer ist schnell genug zum Rendern und Aktualisieren, um eine unbedeutende Zeit in Anspruch zu nehmen. Schalten Sie also vsync ein und schreiben Sie diese Spieleschleife:
while(running) { update(); render(); display(); }
Sehr einfach! Jetzt arbeitet das Spiel mit 60 fps und alles läuft wie am Schnürchen. Fertig. Vielen Dank für das Lesen dieses Beitrags.
Nun, offensichtlich ist nicht alles so gut. Was ist, wenn jemand einen schwachen Computer hat, der das Spiel nicht mit einer Geschwindigkeit rendern kann, die ausreicht, um 60 fps bereitzustellen? Was wäre, wenn jemand einen dieser coolen neuen 144-Hertz-Monitore kaufen würde? Was ist, wenn er vsync in den Treibereinstellungen deaktiviert hat?
Sie könnten denken: Ich muss die Zeit irgendwo messen und ein Update mit der richtigen Häufigkeit bereitstellen. Dies ist ganz einfach: Sammeln Sie einfach die Zeit in jedem Zyklus und aktualisieren Sie sie jedes Mal, wenn sie den Schwellenwert um 1/60 Sekunde überschreitet.
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); accumulator += deltaTime; while(accumulator > 1.0/60.0){ update(); accumulator -= 1.0/60.0; } render(); display(); }
Fertig, nirgendwo einfacher. Tatsächlich gibt es eine Reihe von Spielen, bei denen der Code im Wesentlichen so aussieht. Das ist aber falsch. Dies ist zum Einstellen des Timings geeignet, führt jedoch zu Problemen mit Ruckeln (Stottern) und anderen Fehlanpassungen. Ein solches Problem ist sehr häufig: Frames werden nicht genau 1/60 Sekunde angezeigt; Selbst wenn vsync aktiviert ist, gibt es in der Zeit, in der sie angezeigt werden (und in der Genauigkeit des Betriebssystem-Timers), immer ein kleines Rauschen. Daher wird es Situationen geben, in denen Sie einen Frame rendern und das Spiel glaubt, dass die Zeit für die erneute Aktualisierung noch nicht gekommen ist (da der Akku nur einen winzigen Bruchteil hinterherhinkt). Daher wird derselbe Frame nur noch einmal wiederholt, aber jetzt ist das Spiel zu spät für den Frame und verdoppelt sich Update. Hier ist das Zucken!
Beim Googeln finden Sie mehrere vorgefertigte Lösungen, um dieses Zucken zu beseitigen. Zum Beispiel kann ein Spiel eine Variable anstelle eines konstanten Zeitschritts verwenden und die Batterien im Zeitcode einfach ganz aufgeben. Oder Sie können einen konstanten Zeitschritt mit einem interpolierenden Renderer implementieren, der in einem ziemlich bekannten Artikel "
Fix Your Timestep " von Glenn Fielder beschrieben wird. Oder Sie können den Timer-Code neu erstellen, damit er etwas flexibler ist, wie im Beitrag zu
Frame-Timing-Problemen von Slick Entertainment beschrieben (dieser Blog ist leider nicht mehr vorhanden).
Fuzzy Timings
Die Slick Entertainment-Methode mit „Fuzzy Timings“ in meiner Engine war am einfachsten zu implementieren, da keine Änderungen in der Spielelogik und im Rendering erforderlich waren. Also in
The End is Nigh habe ich es benutzt. Es war genug, um es nur in den Motor einzusetzen. Tatsächlich kann das Spiel einfach „etwas früher“ aktualisiert werden, um Probleme mit Timing-Fehlanpassungen zu vermeiden. Wenn das Spiel vsync enthält, können Sie vsync nur als Haupttimer des Spiels verwenden und erhalten ein flüssiges Bild.
So sieht der Update-Code jetzt aus (das Spiel kann mit 62 fps "funktionieren", verarbeitet aber immer noch jeden Zeitschritt so, als ob es mit 60 fps funktioniert. Ich verstehe nicht ganz, warum ich ihn einschränken soll, damit die Batteriewerte nicht unter 0 fallen, aber ohne Dieser Code funktioniert nicht. Sie können es folgendermaßen interpretieren: „Das Spiel wird mit einem festen Schritt aktualisiert, wenn es im Intervall von 60 fps bis 62 fps gerendert wird.“
while(accumulator > 1.0/62.0){ update(); accumulator -= 1.0/60.0; if(accumulator < 0) accumulator = 0; }
Wenn vsync aktiviert ist, kann das Spiel im Wesentlichen mit einer festen Tonhöhe arbeiten, die der Bildwiederholfrequenz des Monitors entspricht und ein flüssiges Bild liefert. Das Hauptproblem hierbei ist, dass das Spiel bei deaktivierter vsync-Funktion
etwas schneller funktioniert, der Unterschied jedoch so
gering ist, dass niemand ihn bemerkt.
Speedrunner. Speedrunner werden es bemerken. Kurz nach der Veröffentlichung des Spiels stellten sie fest, dass einige Leute auf den Speedran-Highscore-Listen schlechtere Reisezeiten hatten, aber es stellte sich heraus, dass es besser war als andere. Der unmittelbare Grund dafür war das unklare Timing und die Unterbrechung von vsync im Spiel (oder 144-Hz-Monitoren). Daher wurde klar, dass Sie diese Unschärfe deaktivieren müssen, wenn Sie vsync trennen.
Oh, aber wir können immer noch nicht überprüfen, ob vsync deaktiviert ist. Im Betriebssystem gibt es keine Aufrufe dafür, und obwohl wir von der Anwendung anfordern können, vsync zu aktivieren oder zu deaktivieren, hängt dies tatsächlich vollständig vom Betriebssystem und dem Grafiktreiber ab. Das einzige, was getan werden kann, ist, eine Reihe von Frames zu rendern, die Ausführungszeit dieser Aufgabe zu messen und dann zu vergleichen, ob sie ungefähr dieselbe Zeit in Anspruch nehmen. Genau das habe ich für
The End is Nigh getan. Wenn das Spiel kein vsync mit einer Frequenz von 60 Hz enthält, wird mit „strengen 60 fps“ auf den ursprünglichen Frame-Timer zurückgesetzt. Außerdem habe ich der Konfigurationsdatei einen Parameter hinzugefügt, der das Spiel dazu zwingt, keine Unschärfe zu verwenden (hauptsächlich für Speedrunner, die eine genaue Zeit benötigen), und einen genauen Timer-Handler im Spiel hinzugefügt, der die Verwendung von Autosplitter ermöglicht (dies ist ein Skript, das mit einem Atomzeit-Timer arbeitet).
Einige Benutzer beschwerten sich immer noch über das gelegentliche Ruckeln einzelner Frames, aber sie schienen so selten zu sein, dass sie durch Betriebssystemereignisse oder andere externe Gründe erklärt werden konnten. Keine große Sache. Richtig?
Als ich kürzlich meinen Timer-Code durchgesehen habe, ist mir etwas Seltsames aufgefallen. Die Batterie wurde verschoben, jeder Frame dauerte etwas länger als 1/60 Sekunde, so dass das Spiel von Zeit zu Zeit dachte, es sei zu spät für den Frame, und führte ein doppeltes Update durch. Es stellte sich heraus, dass mein Monitor mit einer Frequenz von 59,94 Hz und nicht mit 60 Hz arbeitet. Dies bedeutete, dass er alle 1000 Frames ein doppeltes Update durchführen musste, um „aufzuholen“. Dies ist jedoch sehr einfach zu beheben - ändern Sie einfach das Intervall der zulässigen Bildfrequenzen (nicht von 60 auf 62, sondern von 59 auf 61).
while(accumulator > 1.0/61.0){ update(); accumulator -= 1.0/59.0; if(accumulator < 0) accumulator = 0; }
Das oben beschriebene Problem mit getrennten vsync- und Hochfrequenzmonitoren besteht weiterhin, und es gilt dieselbe Lösung (Rollback auf strengen Timer, wenn der Monitor
nicht um 60 vsync synchronisiert ist).
Aber woher wissen Sie, ob dies die richtige Lösung ist? Wie kann sichergestellt werden, dass es auf allen Computerkombinationen mit verschiedenen Monitortypen mit und ohne aktiviertem vsync usw. ordnungsgemäß funktioniert? Es ist sehr schwierig, all diese Timerprobleme im Kopf im Auge zu behalten und zu verstehen, was die Desynchronisation, seltsame Schleifen und dergleichen verursacht.
Simulator überwachen
Beim Versuch, eine zuverlässige Lösung für das „Problem des 59,94-Hertz-Monitors“ zu finden, wurde mir klar, dass ich nicht nur Versuche und Fehlerprüfungen durchführen konnte, in der Hoffnung, eine zuverlässige Lösung zu finden. Ich brauchte eine bequeme Möglichkeit, verschiedene Versuche zu testen, einen hochwertigen Timer zu schreiben, und eine einfache Möglichkeit, um zu überprüfen, ob es in verschiedenen Monitorkonfigurationen zu einem Ruckeln oder einer Zeitverschiebung kommt.
Der Monitorsimulator wird in der Szene angezeigt. Dies ist der „schmutzige und schnelle“ Code, den ich geschrieben habe, der den „Monitorbetrieb“ simuliert und mir im Wesentlichen eine Reihe von Zahlen zeigt, die eine Vorstellung von der Stabilität jedes getesteten Timers geben.
Für den einfachsten Timer werden beispielsweise die folgenden Werte ab dem Anfang des Artikels angezeigt:
20211012021011202111020211102012012102012[...]
TOTAL UPDATES: 10001
TOTAL VSYNCS: 10002
TOTAL DOUBLE UPDATES: 2535
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.683
SYSTEM TIME: 166.7
Zunächst zeigt der Code für jedes emulierte vsync die Anzahl der "Aktualisierungen" des Spielzyklus nach dem vorherigen vsync an. Alle anderen Werte als durchgehend 1 führen zu einem zuckenden Bild. Am Ende zeigt der Code die akkumulierten Statistiken an.
Bei Verwendung des „Fuzzy-Timers“ (mit einem Intervall von 60 bis 62 fps) auf einem 59,94-Hertz-Monitor zeigt der Code Folgendes an:
111111111111111111111111111111111111111111111[...]
TOTAL UPDATES: 10000
TOTAL VSYNCS: 9991
TOTAL DOUBLE UPDATES: 10
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.667
SYSTEM TIME: 166.683
Das Ruckeln des Rahmens ist sehr selten, so dass es bei einer solchen Zahl von 1 schwierig sein kann, es zu bemerken. Die angezeigten Statistiken zeigen jedoch deutlich, dass das Spiel hier mehrere doppelte Aktualisierungen durchgeführt hat, was zu Ruckeln führt. In der festen Version (mit einem Intervall von 59 bis 61 fps) werden 0 übersprungen oder doppelt aktualisiert.
Sie können vsync auch deaktivieren. Der Rest der Statistikdaten wird unwichtig, aber dies zeigt mir deutlich die Größe der „Zeitverschiebung“ (die Systemzeitverschiebung relativ zu dem Ort, an dem die Spielzeit sein sollte).
GAME TIME: 166.667
SYSTEM TIME: 169.102
Wenn vsync deaktiviert ist, müssen Sie daher zu einem strengen Timer wechseln, da sich diese Diskrepanzen sonst im Laufe der Zeit ansammeln.
Wenn ich die Renderzeit auf .02 setze (dh zum Rendern wird „mehr als ein Frame“ benötigt), bekomme ich ein Zucken. Idealerweise sollte das Spielmuster wie 202020202020 aussehen, ist aber etwas ungleichmäßig.
In dieser Situation verhält sich dieser Timer etwas besser als der vorherige, aber es wird verwirrender und schwieriger herauszufinden, wie und warum er funktioniert. Aber ich kann die Tests einfach in diesen Simulator einfügen und überprüfen, wie sie sich verhalten, und Sie können die Gründe später herausfinden. Versuch und Irrtum, Baby!
while(accumulator >= 1.0/61.0){ simulate_update(); accumulator -= 1.0/60.0; if(accumulator < 1.0/59.0–1.0/60.0) accumulator = 0; }
Sie können
einen Monitorsimulator herunterladen und verschiedene Zeitberechnungsmethoden unabhängig voneinander überprüfen.
Mailen Sie mir, wenn Sie etwas Besseres finden.
Ich bin nicht 100% zufrieden mit meiner Entscheidung (es erfordert immer noch einen Hack mit "vsync-Erkennung" und gelegentliches Ruckeln kann während der Desynchronisation auftreten), aber ich glaube, dass dies fast so gut ist wie der Versuch, einen Spielzyklus mit einem festen Schritt zu implementieren. Ein Teil dieses Problems entsteht, weil es sehr schwierig ist, die Parameter dessen zu bestimmen, was hier als "akzeptabel" angesehen wird. Die Hauptschwierigkeit liegt im Kompromiss zwischen Zeitverschiebung und doppelt / übersprungenen Frames. Wenn Sie ein 60-Hz-Spiel auf einem 50-Hz-PAL-Monitor ausführen ... was ist dann die richtige Entscheidung? Möchten Sie wildes Ruckeln oder ein merklich langsameres Spiel? Beide Optionen scheinen schlecht zu sein.
Separates Rendern
In früheren Methoden habe ich beschrieben, was ich als "Lockstep-Rendering" bezeichne. Das Spiel aktualisiert seinen Status, rendert dann und zeigt beim Rendern immer den neuesten Status des Spiels an. Rendern und Aktualisieren sind miteinander verbunden.
Aber Sie können sie trennen. Dies ist genau das, was die im
Beitrag "
Fix Your Timestep " beschriebene Methode
bewirkt . Ich werde mich nicht wiederholen, du solltest diesen Beitrag unbedingt lesen. Dies ist (wie ich es verstehe) der „Industriestandard“, der in AAA-Spielen und Engines wie Unity und Unreal verwendet wird (in intensiven aktiven 2D-Spielen bevorzugen sie jedoch normalerweise die Verwendung eines festen Schritts (Lockstep), da manchmal die Genauigkeit, die Sie erhalten diese Methode).
Wenn wir jedoch Glenns Beitrag kurz beschreiben, beschreibt er einfach die Aktualisierungsmethode mit einer festen Bildrate. Beim Rendern wird jedoch eine Interpolation zwischen dem aktuellen und dem vorherigen Status des Spiels durchgeführt und der aktuelle Batteriewert als Interpolationswert verwendet. Mit dieser Methode können Sie mit jeder Bildrate rendern und das Spiel mit jeder Frequenz aktualisieren, und das Bild ist immer glatt. Kein Ruckeln, funktioniert universell.
while(running){ computeDeltaTimeSomehow(); accumulator += deltaTime; while(accumulator >= 1.0/60.0){ previous_state = current_state; current_state = update(); accumulator -= 1.0/60.0; } render_interpolated_somehow(previous_state, current_state, accumulator/(1.0/60.0)); display(); }
Also elementar. Das Problem ist gelöst.
Jetzt müssen Sie nur noch sicherstellen, dass das Spiel die interpolierten Zustände wiedergeben kann ... aber Moment mal, es ist wirklich gar nicht so einfach. In Glenns Beitrag wird einfach angenommen, dass dies möglich ist. Es ist leicht genug, die vorherige Position des Spielobjekts zwischenzuspeichern und seine Bewegungen zu interpolieren, aber der Spielstatus ist viel mehr als das. Es ist notwendig, den Zustand der Animation, die Schaffung und Zerstörung von Objekten und eine Reihe von Dingen zu berücksichtigen.
Außerdem müssen Sie in der Logik des Spiels berücksichtigen, ob das Objekt teleportiert wird oder ob es reibungslos bewegt werden muss, damit der Interpolator keine falschen Annahmen über den Pfad des Spielobjekts zu seiner aktuellen Position macht. Bei Drehungen kann echtes Chaos auftreten, insbesondere wenn sich die Drehung eines Objekts in einem Frame um mehr als 180 Grad ändern kann. Und wie werden erstellte und zerstörte Objekte richtig verarbeitet?
Im Moment arbeite ich gerade an dieser Aufgabe in meinem Motor. Tatsächlich interpoliere ich nur die Bewegungen und lasse alles andere so wie es ist. Sie werden kein Ruckeln bemerken, wenn sich das Objekt nicht reibungslos bewegt. Daher wird das Überspringen von Animationsframes und das Synchronisieren der Erstellung / Zerstörung des Objekts bis zu einem Frame kein Problem, wenn alles andere reibungslos ausgeführt wird.
Es ist jedoch seltsam, dass diese Methode das Spiel tatsächlich in einen Zustand versetzt, der um 1 Zustand des Spiels verspätet ist, von dem aus sich die Simulation jetzt befindet. Dies ist unauffällig, kann jedoch mit anderen Verzögerungsquellen in Verbindung gebracht werden, z. B. Eingangsverzögerungen und Aktualisierungsraten. Daher werden diejenigen, die das reaktionsschnellste Gameplay benötigen (ich spreche von Ihnen, Speedrunner), höchstwahrscheinlich die Verwendung von Lockstep im Spiel bevorzugen.
In meinem Motor gebe ich nur eine Wahl. Wenn Sie einen 60-Hertz-Monitor und einen schnellen Computer haben, ist es am besten, Lockstep mit aktiviertem vsync zu verwenden. Wenn der Monitor eine nicht standardmäßige Bildwiederholfrequenz aufweist oder Ihr schwacher Computer nicht ständig 60 Bilder pro Sekunde rendern kann, aktivieren Sie die Bildinterpolation. Ich möchte diese Option "Framerate entsperren" nennen, aber die Leute denken vielleicht, dass dies einfach "diese Option aktivieren, wenn Sie einen guten Computer haben" bedeutet. Dieses Problem kann jedoch später gelöst werden.
Tatsächlich gibt
es eine Methode, um dieses Problem zu umgehen.
Variable Zeitschrittaktualisierungen
Viele Leute fragten mich, warum man das Spiel nicht einfach mit einem variablen Zeitschritt aktualisiert, und theoretische Programmierer sagen oft: "Wenn das Spiel RICHTIG geschrieben ist, kann man es einfach mit einem beliebigen Zeitschritt aktualisieren."
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); update(deltaTime); render(); display(); }
Keine Kuriositäten mit Timings. Kein seltsames Interpolations-Rendering. Alles ist einfach, alles funktioniert.
Also elementar. Das Problem ist gelöst. Und jetzt für immer! Es ist unmöglich, ein besseres Ergebnis zu erzielen!
Jetzt ist es einfach genug, die Spiellogik mit einem beliebigen Zeitschritt zum Laufen zu bringen. Es ist ganz einfach, ersetzen Sie einfach den gesamten Code:
position += speed;
dazu:
position += speed * deltaTime;
und ersetzen Sie den folgenden Code:
speed += acceleration; position += speed;
dazu:
speed += acceleration * deltaTime; position += speed * deltaTime;
und ersetzen Sie den folgenden Code:
speed += acceleration; speed *= friction; position += speed;
dazu:
Vec3D p0 = position; Vec3D v0 = velocity; Vec3D a = acceleration*(1.0/60.0); double f = friction; double n = dt*60; double fN = pow(friction, n); position = p0 + ((f*(a*(f*fN-f*(n+1)+n)+(f-1)*v0*(fN-1)))/((f-1)*(f-1)))*(1.0/60.0); velocity = v0*fN+a*(f*(fN-1)/(f-1));
... also warte
Woher kam das alles?
Der letzte Teil wird buchstäblich aus dem Hilfscode meines Motors kopiert, der "wirklich korrekte, bildratenunabhängige Bewegung mit reibungsbegrenzender Geschwindigkeit" ausführt. Es ist ein bisschen Müll drin (diese Multiplikationen und Divisionen durch 60). Dies ist jedoch die „richtige“ Version des Codes mit einem variablen Zeitschritt für das vorherige Fragment. Ich habe es über eine Stunde lang mit
Wolfram Alpha herausgefunden .
Jetzt fragen sie mich vielleicht, warum sie es nicht so machen:
speed += acceleration * deltaTime; speed *= pow(friction, deltaTime); position += speed * deltaTime;
Und obwohl es zu funktionieren scheint, ist es tatsächlich falsch, dies zu tun. Sie können es selbst überprüfen. Führen Sie zwei Aktualisierungen mit deltaTime = 1 und anschließend eine Aktualisierung mit deltaTime = 2 durch. Die Ergebnisse sind unterschiedlich. Normalerweise bemühen wir uns, dass das Spiel zusammenarbeitet, daher sind solche Diskrepanzen nicht erwünscht. Dies ist wahrscheinlich eine ausreichend gute Lösung, wenn Sie sicher sind, dass deltaTime immer ungefähr einem Wert entspricht, Sie dann jedoch Code schreiben müssen, um sicherzustellen, dass Aktualisierungen mit einer konstanten Häufigkeit durchgeführt werden und ... ja. Das ist richtig, jetzt versuchen wir alles "RICHTIG" zu machen.
Wenn sich solch ein winziger Code zu monströsen mathematischen Berechnungen entfaltet, stellen Sie sich komplexere Bewegungsmuster vor, an denen viele interagierende Objekte beteiligt sind, und dergleichen. Jetzt können Sie deutlich sehen, dass die „richtige“ Lösung nicht realisierbar ist. Das Maximum, das wir erreichen können, ist eine „grobe Annäherung“. Vergessen wir es jetzt und nehmen wir an, dass wir tatsächlich eine „wirklich korrekte“ Version der Bewegungsfunktionen haben. Großartig, richtig?
Nein eigentlich. Hier ist ein reales Beispiel für das Problem, das ich damit in
Bombernauts hatte . Ein Spieler kann ungefähr 1 Plättchen abprallen lassen, und das Spiel findet in einem Blockraster auf 1 Plättchen statt. Um auf einem Block zu landen, müssen sich die Beine des Charakters über die Oberseite des Blocks erheben.
Da die Erkennung von Kollisionen hier jedoch mit einem diskreten Schritt erfolgt, erreichen die Beine bei einer niedrigen Bildrate manchmal nicht die Oberfläche des Plättchens, obwohl sie der gleichen Bewegungskurve folgen, und anstatt den Spieler anzuheben, rutschen sie von der Wand.
Offensichtlich ist dieses Problem lösbar. Es zeigt jedoch die Arten von Problemen, auf die wir stoßen, wenn wir versuchen, die Arbeit des Spielzyklus mit einem variablen Zeitschritt korrekt umzusetzen. Wir verlieren Kohärenz und Determinismus, daher müssen wir die Wiedergabefunktionen des Spiels beseitigen, indem wir die Eingaben des Spielers, den deterministischen Mehrspielermodus und dergleichen aufzeichnen. Für reflexbasierte schnelle 2D-Spiele ist Konsistenz äußerst wichtig (und nochmals Hallo, um Läufer zu beschleunigen).
Wenn Sie versuchen, die Zeitschritte so anzupassen, dass sie weder zu groß noch zu klein sind, verlieren Sie den Hauptvorteil des variablen Zeitschritts und können die beiden anderen hier beschriebenen Methoden sicher anwenden. Das Spiel ist die Kerze nicht wert. Die Spiellogik (die Implementierung der korrekten Bewegungsmathematik) erfordert zu viel zusätzlichen Aufwand, und im Bereich des Determinismus und der Konsistenz werden zu viele Opfer benötigt. Ich würde diese Methode nur für ein musikalisches Rhythmus-Spiel verwenden (bei dem die Bewegungsgleichungen einfach sind und maximale Reaktionsfähigkeit und Geschmeidigkeit erfordern). In allen anderen Fällen werde ich ein festes Update wählen.
Fazit
Jetzt wissen Sie, wie Sie das Spiel mit einer konstanten Frequenz von 60 fps zum Laufen bringen. Dies ist trivial einfach und niemand sollte ein Problem damit haben. Es gibt
keine weiteren Probleme, die diese Aufgabe erschweren.