Auf dem Level können Tausende von Feinden sein.Defender's Quest: Das Tal des vergessenen DX hatte schon immer langjährige Probleme mit der Geschwindigkeit, und ich habe es endlich geschafft, sie zu lösen. Der Hauptanreiz für eine massive Geschwindigkeitssteigerung war unser
Port auf der PlayStation Vita . Das Spiel wurde bereits auf dem PC veröffentlicht und funktionierte auf der
Xbox One mit der
PS4 gut, wenn nicht sogar perfekt. Aber ohne eine wesentliche Verbesserung des Spiels könnten wir es niemals auf Vita starten.
Wenn ein Spiel langsamer wird, geben Kommentatoren im Internet normalerweise einer Programmiersprache oder einer Engine die Schuld. Es ist wahr, dass Sprachen wie C # und Java teurer sind als C und C ++, und Tools wie Unity haben unlösbare Probleme, wie z. B. die Speicherbereinigung. Tatsächlich kommen die Leute auf solche Erklärungen, weil Sprache und Engine die offensichtlichsten Eigenschaften von Software sind. Aber die wahren Mörder der Leistung können dumme winzige Details sein, die nichts mit Architektur zu tun haben.
0. Profiling Tools
Es gibt nur einen wirklichen Weg, um das Spiel schneller zu machen - das Erstellen von Profilen. Finden Sie heraus, wofür der Computer zu viel Zeit verbringt, und verbringen Sie weniger Zeit damit, oder, noch besser, verschwenden Sie überhaupt keine Zeit.
Das einfachste Profiling-Tool ist der Standard-Windows-Systemmonitor (Leistungsmonitor):
Tatsächlich ist dies ein ziemlich flexibles Werkzeug und es ist sehr einfach, damit zu arbeiten. Drücken Sie einfach Strg + Alt + Entf, öffnen Sie den "Task-Manager" und klicken Sie auf die Registerkarte "Leistung". Führen Sie jedoch nicht zu viele andere Programme aus. Wenn Sie genau hinschauen, können Sie leicht Spitzen in der CPU-Auslastung und sogar Speicherlecks erkennen. Dies ist ein nicht informativer Weg, aber es kann der erste Schritt sein, langsame Orte zu finden.
Defender's Quest ist in der Hochsprache
Haxe geschrieben, die in andere Sprachen kompiliert wurde (mein Hauptziel war C ++). Dies bedeutet, dass jedes Tool, das C ++ - Profile erstellen kann, auch meinen von Haxe generierten C ++ - Code profilieren kann. Als ich die Ursachen der Probleme verstehen wollte, startete ich den Performance Explorer von Visual Studio aus:
Darüber hinaus haben verschiedene Konsolen ihre eigenen Profiling-Tools, was sehr praktisch ist, aber aufgrund der NDA kann ich Ihnen nichts darüber erzählen. Aber wenn Sie Zugriff auf sie haben, aber stellen Sie sicher, dass Sie sie verwenden!
Anstatt ein schreckliches Tutorial über die Verwendung von Profiling-Tools wie Performance Explorer zu schreiben, lasse ich einfach einen Link zur
offiziellen Dokumentation und gehe zum Hauptthema über - erstaunliche Dinge, die zu einer enormen Steigerung der Produktivität geführt haben, und wie ich sie gefunden habe !
1. Problemerkennung
Die Spieleleistung ist nicht nur die Geschwindigkeit selbst, sondern auch die Wahrnehmung. Defender's Quest ist ein Tower Defense-Genre-Spiel, das mit 60 FPS gerendert wird, jedoch eine variable Spielgeschwindigkeit im Bereich von 1 / 4x bis 16x aufweist. Unabhängig von der Spielgeschwindigkeit verwendet die Simulation einen
festen Zeitstempel mit 60 Aktualisierungen pro Sekunde und 1x Simulationszeit. Das heißt, wenn Sie das Spiel mit einer Geschwindigkeit von 16x ausführen, funktioniert die Aktualisierungslogik tatsächlich mit einer Frequenz von
960 FPS . Ehrlich gesagt, das sind zu hohe Anfragen für das Spiel! Aber ich habe diesen Modus erstellt, und wenn sich herausstellt, dass er langsam ist, werden die Spieler ihn definitiv bemerken.
Und im Spiel gibt es
so ein Level:
Dies ist die letzte Bonusschlacht "Endless 2", es ist auch "mein persönlicher Albtraum". Der Screenshot wurde im New Game + -Modus aufgenommen, in dem die Feinde nicht nur viel stärker sind, sondern auch Funktionen wie die Wiederherstellung der Gesundheit haben. Die Lieblingsstrategie des Spielers besteht darin, Drachen auf das maximale Roar-Level zu pumpen (AOE-Angriff, der Feinde betäubt) und eine Reihe von Rittern hinter sich zu lassen, deren Rückstoß maximal gepumpt ist, um alle, die an den Drachen vorbeikommen, in ihren Aktionsbereich zurückzuschieben. Der kumulative Effekt ist, dass eine riesige Gruppe von Monstern endlos an einem Ort bleibt, viel länger als die Spieler überleben müssten, wenn sie sie tatsächlich töten würden. Da die Spieler
auf Wellen
warten müssen und sie nicht
töten müssen, um Belohnungen und Erfolge zu erhalten, ist eine solche Strategie absolut effektiv und brillant - genau das ist das Verhalten der Spieler, die ich stimuliert habe.
Leider stellt sich heraus, dass dies auch ein
pathologischer Fall für die Leistung ist,
insbesondere wenn Spieler mit 16- oder 8-facher Geschwindigkeit spielen möchten. Natürlich werden nur die Hardcore-Spieler versuchen, die Errungenschaft „Hundertste Welle“ in New Game + auf der Stufe Endless 2 zu erreichen, aber sie sind nur diejenigen, die das Spiel am lautesten sprechen, deshalb wollte ich, dass sie glücklich sind.
Es ist nur ein 2D-Spiel mit einer Reihe von Sprites. Was könnte daran falsch sein?
Und in der Tat. Lass es uns richtig machen.
2. Kollisionsauflösung
Schauen Sie sich diesen Screenshot an:
Sehen Sie diesen Bagel um den Waldläufer? Dies ist ihr Aufprallbereich - beachten Sie, dass es auch eine tote Zone gibt, in der es
keine Ziele treffen kann. Jede Klasse hat ihren eigenen Angriffsbereich und jeder Verteidiger hat einen anderen Größenbereich, abhängig von der Stufe des Boosts und den persönlichen Parametern. Und theoretisch kann jeder Verteidiger auf jeden Feind in seiner Reichweite zielen. Gleiches gilt für bestimmte Arten von Feinden. Es können bis zu 36 Verteidiger auf der Karte sein (ohne die Hauptfigur Azru), aber es gibt keine Obergrenze für die Anzahl der Feinde. Jeder Verteidiger und Feind verfügt über eine Liste möglicher Ziele, die auf der Grundlage von Aufrufen erstellt wurde, um den Bereich bei jedem Aktualisierungsschritt zu überprüfen (abzüglich der logischen Abschaltung derjenigen, die im Moment nicht angreifen können, usw.).
Videoprozessoren sind heute sehr schnell - wenn Sie sie nicht zu stark belasten, können sie fast eine beliebige Anzahl von Polygonen verarbeiten. Aber selbst die schnellsten CPUs haben sehr leicht „Engpässe“ bei einfachen Verfahren, insbesondere bei solchen, die exponentiell wachsen. Das ist der Grund, warum sich ein 2D-Spiel als langsamer herausstellen kann als ein viel schöneres 3D-Spiel - nicht weil der Programmierer es nicht bewältigen konnte (vielleicht auch zumindest in meinem Fall), sondern im Prinzip, weil die Logik manchmal teurer sein kann. als zeichnen! Die Frage ist nicht, wie viele Objekte sich auf dem Bildschirm befinden, sondern was sie
tun .
Lassen Sie uns die Kollisionserkennung untersuchen und beschleunigen. Zum Vergleich möchte ich sagen, dass die Kollisionserkennung vor der Optimierung bis zu ~ 50% der CPU-Zeit im Hauptkampfzyklus in Anspruch nahm. Nach der Optimierung weniger als 5%.
Es dreht sich alles um Quadrantenbäume
Die Hauptlösung für das Problem der langsamen Kollisionserkennung besteht darin
, den Raum aufzuteilen - und von Anfang an haben wir eine qualitativ hochwertige Implementierung
des Quadrantenbaums verwendet . Im Wesentlichen wird der Raum effektiv getrennt, sodass viele optionale Kollisionsprüfungen übersprungen werden können.
In jedem Frame aktualisieren wir den gesamten Quadrantenbaum (QuadTree), um die Position jedes Objekts zu verfolgen. Wenn der Feind oder Verteidiger auf jemanden zielen möchte, bittet er QuadTree um eine Liste der Objekte in der Nähe. Der Profiler sagte uns jedoch, dass beide Operationen viel langsamer sind, als sie sein sollten.
Was ist hier falsch?
Wie sich herausstellte - viel.
String-Eingabe
Da ich sowohl Feinde als auch Verteidiger in einem Quadrantenbaum hielt, musste ich angeben, wonach ich suchte, und dies geschah folgendermaßen:
var things:Array<XY> = _qtree.queryRange(zone.bounds, "e"); //"e" - "enemy"
Im Fachjargon der Programmierer wird dies als
Zeichenfolgentypcode bezeichnet. Dies ist unter anderem deshalb schlecht, weil Zeichenfolgenvergleiche immer langsamer sind als Ganzzahlvergleiche.
Ich nahm schnell ganzzahlige Konstanten auf und ersetzte den Code durch diesen:
var things:Array<XY> = _qtree.queryRange(zone.bounds, QuadTree.ENEMY);
(Ja, es hat sich wahrscheinlich gelohnt,
Enum Abstract für maximale
Typensicherheit zu verwenden, aber ich hatte es eilig und musste zuerst die Arbeit erledigen.)
Allein diese Änderung hat einen
großen Beitrag geleistet, da diese Funktion jedes Mal, wenn jemand eine neue Liste von Zielen benötigt,
ständig und rekursiv aufgerufen wird.
Array gegen Vektor
Schauen Sie sich das an:
var things:Array<XY>
Haxe-Arrays sind ActionScript- und JS-Arrays insofern sehr ähnlich, als sie Sammlungen von Objekten mit veränderbarer Größe sind, aber in Haxe sind sie stark typisiert.
Es gibt jedoch eine andere Datenstruktur, die mit statischen Zielsprachen wie cpp effizienter ist, nämlich
haxe.ds.Vector . Haxe-Vektoren sind im Wesentlichen dieselben wie Arrays, außer dass sie beim Erstellen eine feste Größe erhalten.
Da meine Quadrantenbäume bereits ein festes Volumen hatten, habe ich Arrays durch Vektoren ersetzt, um eine spürbare Geschwindigkeitssteigerung zu erzielen.
Fordern Sie nur das an, was Sie brauchen
Zuvor hat meine
queryRange
Funktion eine Liste von Objekten und
XY
Instanzen zurückgegeben. Sie enthielten die x / y-Koordinaten des referenzierten Spielobjekts und seine eindeutige Ganzzahlkennung (Suchindex im Hauptarray). Das Spielobjekt, das die Anforderung ausführt, hat diese XYs empfangen, eine Ganzzahlkennung extrahiert, um sein Ziel zu erhalten, und dann den Rest vergessen.
Warum sollte ich all diese Verweise für jeden QuadTree-Knoten
rekursiv und sogar
960 Mal pro Frame an XY-Objekte übergeben
? Es reicht mir, eine Liste von Ganzzahlkennungen zurückzugeben.
PROFESSIONELLER HINWEIS: Ganzzahlen lassen sich viel schneller übertragen als fast alle anderen Datentypen!Im Vergleich zu anderen Korrekturen war dies recht einfach, aber das Leistungswachstum war immer noch spürbar, da diese interne Schleife sehr aktiv genutzt wurde.
Optimierung der Schwanzrekursion
Es gibt eine elegante Sache namens
Tail-Call-Optimierung . Es ist schwer zu erklären, deshalb zeige ich Ihnen besser ein Beispiel.
Es war:
nw.queryRange(Range, -1, result);
ne.queryRange(Range, -1, result);
sw.queryRange(Range, -1, result);
se.queryRange(Range, -1, result);
return result;
Es wurde:
return se.queryRange(Range, filter, sw.queryRange(Range, filter, ne.queryRange(Range, filter, nw.queryRange(Range, filter, result))));
Der Code gibt die gleichen logischen Ergebnisse zurück, aber laut Profiler ist die zweite Option schneller, zumindest bei der Übersetzung in cpp. Beide Beispiele führen genau die gleiche Logik aus: Sie nehmen Änderungen an der Ergebnisdatenstruktur vor und übergeben sie an die nächste Funktion, bevor sie zurückkehren. Wenn wir dies rekursiv tun, können wir vermeiden, dass der Compiler temporäre Referenzen generiert, da er das Ergebnis der vorherigen Funktion einfach sofort zurückgeben kann, anstatt sich in einem zusätzlichen Schritt daran zu halten. Oder so ähnlich. Ich verstehe nicht ganz, wie das funktioniert, also lies den Beitrag unter dem obigen Link.
(Nach meinem Wissen verfügt die aktuelle Version des Haxe-Compilers nicht über eine Optimierungsfunktion für die Schwanzrekursion, das heißt, es ist wahrscheinlich die Arbeit des C ++ - Compilers. Seien Sie also nicht überrascht, wenn dieser Trick bei der Übersetzung von Haxe-Code nicht in cpp funktioniert.)
Objekt-Pooling
Wenn ich genaue Ergebnisse benötige, muss ich QuadTree bei jedem Update-Aufruf zerstören und neu erstellen. Das Erstellen neuer QuadTree-Instanzen ist eine recht häufige Aufgabe, aber bei einer großen Anzahl neuer AABB- und XY-Objekte führten die von ihnen abhängigen QuadTrees zu einer starken Speicherüberlastung. Da es sich um sehr einfache Objekte handelt, wäre es logisch, viele solcher Objekte im Voraus zuzuweisen und sie nur ständig wiederzuverwenden. Dies wird als
Objektpool bezeichnet .
Ich habe so etwas gemacht:
nw = new QuadTree( new AABB( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( new AABB( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( new AABB( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( new AABB( cx + hs2x, cy + hs2y, hs2x, hs2y) );
Aber dann habe ich den Code durch folgenden ersetzt:
nw = new QuadTree( AABB.get( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( AABB.get( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( AABB.get( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( AABB.get( cx + hs2x, cy + hs2y, hs2x, hs2y) );
Wir verwenden das Open-Source-
HaxeFlixel- Framework und haben dies mithilfe der
FlxPool- HaxeFlixel-Klasse implementiert. Bei solch hochspezialisierten Optimierungen ersetze ich häufig einige der grundlegenden Flixel-Elemente (z. B. Kollisionserkennung) durch meine eigene Implementierung (wie bei QuadTrees), aber FlxPool ist besser als alles, was ich selbst geschrieben habe, und es macht genau das, was es benötigt.
Spezialisierung bei Bedarf
Ein
XY
Objekt ist eine einfache Klasse mit den Eigenschaften
x
,
y
und
int_id
. Da es in einer besonders aktiv verwendeten internen Schleife verwendet wurde, konnte ich viele Speicherzuweisungsbefehle und -operationen speichern, indem ich all diese Daten in eine spezielle Datenstruktur verschob, die dieselbe Funktionalität wie
Vector<XY>
bietet. Ich habe diese neue
XYVector
Klasse
XYVector
und das Ergebnis ist hier zu sehen. Dies ist eine sehr hoch spezialisierte Anwendung, die nicht gleichzeitig flexibel ist, aber uns einige Geschwindigkeitsverbesserungen gebracht hat.
Eingebaute Funktionen
Nachdem wir die breite Phase der Kollisionserkennung abgeschlossen haben, müssen wir viele Überprüfungen durchführen, um herauszufinden, welche Objekte tatsächlich kollidieren. Wo immer möglich, versuche ich Punkte und Zahlen zu vergleichen, nicht Zahlen und Zahlen, aber manchmal muss ich Letzteres tun. In jedem Fall erfordert dies alles seine eigenen speziellen Kontrollen:
private static function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; }
All dies kann mit einem einzigen
inline
verbessert werden:
private static inline function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; }
Wenn wir einer Funktion Inline hinzufügen, weisen wir den Compiler an, diesen Code zu kopieren und einzufügen und die Variablen einzufügen, wenn er verwendet wird, und keine externe Funktion extern aufzurufen, was zu unnötigen Kosten führt. Das Einbetten ist nicht immer anwendbar (z. B. erhöht es die Codemenge), ist jedoch ideal für Situationen, in denen kleine Funktionen immer wieder aufgerufen werden.
Wir erinnern an Konflikte
Die eigentliche Lehre hier ist, dass Optimierungen in der realen Welt nicht immer vom gleichen Typ sind. Solche Korrekturen sind eine Mischung aus fortschrittlichen Techniken, billigen Hacks, der Anwendung logischer Empfehlungen und der Beseitigung dummer Fehler. All dies im Allgemeinen gibt uns einen Leistungsschub.
Aber trotzdem -
sieben Mal messen, eins schneiden!Zwei Stunden pedantische Optimierung der Funktion, die alle sechs Frames aufgerufen wird und 0,001 ms dauert, sind trotz der Hässlichkeit und Dummheit des Codes keine Mühe wert.
3. Sortieren Sie alles
Tatsächlich war es eine meiner letzten Verbesserungen, aber es erwies sich als so vorteilhaft, dass es seinen eigenen Titel verdient. Darüber hinaus war es das einfachste und hat sich immer wieder bewährt. Der Profiler zeigte mir ein Verfahren, das ich überhaupt nicht verbessern konnte - die Hauptschleife draw (), die zu lange dauerte. Der Grund war die Funktion, mit der alle Bildschirmelemente vor dem Rendern sortiert wurden - das
Sortieren aller Sprites dauerte viel länger als das Zeichnen!
Wenn Sie sich die Screenshots aus dem Spiel ansehen, werden Sie sehen, dass alle Feinde und Verteidiger zuerst nach
y
und dann nach
x
sortiert sind, sodass sich die Elemente von hinten nach vorne von links nach rechts überlappen, wenn wir uns von links oben nach rechts unten bewegen.
Eine Möglichkeit, die Sortierung zu überlisten, besteht darin, die Rendering-Sortierung einfach durch den Rahmen zu führen. Dies ist ein nützlicher Trick für einige kostspielige Funktionen, führte jedoch sofort zu sehr auffälligen visuellen Fehlern, sodass er nicht zu uns passte.
Schließlich kam die Entscheidung von einem der HaxeFlixel-Betreuer
Jens Fisher . Er fragte: "Haben Sie sichergestellt, dass Sie einen Sortieralgorithmus verwenden, der für fast sortierte Arrays schnell ist?"
Nein! Es stellte sich heraus, dass nein. Ich habe die Array-Sortierung aus der Haxe-Standardbibliothek verwendet (ich denke, es war die
Zusammenführungssortierung - eine gute Wahl für allgemeine Fälle. Aber ich hatte einen ganz
besonderen Fall. Beim Sortieren in jedem Frame ändert die Sortierposition nur eine sehr kleine Anzahl von Sprites, auch wenn es viele davon gibt Ich habe den alten Sortieraufruf durch
Sortieren nach Einsätzen ersetzt und
boom! - die Geschwindigkeit wurde sofort erhöht.
4. Andere technische Probleme
Kollisionserkennung und -sortierung waren große Erfolge in der Logik von
update()
und
draw()
, aber viele weitere Fallstricke waren in aktiv verwendeten internen Schleifen verborgen.
Std.is () und Besetzung
In verschiedenen "heißen" inneren Schleifen hatte ich einen ähnlichen Code:
if(Std.is(something,Type)) { var typed:Type = cast(something,Type); }
In der Haxe-Sprache gibt
Std.is()
, ob ein Objekt zu einem bestimmten Typ (Typ) oder einer Klasse (Klasse) gehört, und
cast
versucht, es während der Programmausführung in einen bestimmten Typ umzuwandeln.
Es gibt sichere und ungeschützte Versionen von
cast
Abgüssen, die zu einer verringerten Leistung führen, ungeschützte Abgüsse jedoch nicht.
Sicher:
cast(something, Type);
Ungeschützt:
var typed:Type = cast something;
Wenn ein unsicherer Zauberversuch fehlschlägt, erhalten wir null, während ein sicherer Zauber eine Ausnahme auslöst. Aber wenn wir keine Ausnahme machen wollen, wozu dann eine sichere Besetzung? Ohne Fang schlägt der Vorgang immer noch fehl, arbeitet jedoch langsamer.
Darüber hinaus ist es sinnlos, einer sicheren
Std.is()
Prüfung
Std.is()
. Der einzige Grund, einen sicheren Cast zu verwenden, ist eine garantierte Ausnahme. Wenn wir jedoch den Typ vor dem Cast überprüfen, garantieren wir bereits, dass der Cast nicht fehlschlägt!
Ich kann die
Std.is()
mit einer
Std.is()
Besetzung etwas beschleunigen, nachdem
Std.is()
überprüft
Std.is()
. Aber warum müssen wir dasselbe neu schreiben, wenn ich den Typ der Klasse überhaupt nicht überprüfen muss?
Angenommen, ich habe ein
CreatureSprite
, das eine Instanz einer Unterklasse von
DefenderSprite
oder
EnemySprite
. Anstatt
Std.is(this,DefenderSprite)
wir in
CreatureSprite
ein Ganzzahlfeld mit Werten wie
CreatureType.DEFENDER
oder
CreatureType.ENEMY
erstellen, die noch schneller überprüft werden.
Ich wiederhole, es lohnt sich, das Problem nur an den Stellen zu beheben, an denen eine deutliche Verlangsamung deutlich erkennbar ist.
Übrigens können Sie mehr über die
sichere und
ungeschützte Besetzung im
Haxe-Handbuch lesen.
Serialisierung / Deserialisierung des Universums
Es war ärgerlich, solche Stellen im Code zu finden:
function copy():SomeClass { return SomeClass.fromXML(this.toXML()); }
Ja. Um ein Objekt zu kopieren,
serialisieren wir
es in XML und
analysieren dann das
gesamte XML. Danach verwerfen wir das XML sofort und geben ein neues Objekt zurück. Dies ist wahrscheinlich der langsamste Weg, ein Objekt zu kopieren. Außerdem wird der Speicher überlastet. Anfangs habe ich XML-Aufrufe geschrieben, um sie zu speichern und von der Festplatte zu laden, und ich glaube, ich war zu faul, um die richtigen Kopierverfahren zu schreiben.
Wahrscheinlich wäre alles in Ordnung, wenn diese Funktion selten verwendet würde, aber diese Aufrufe entstanden an unangemessenen Stellen mitten im Spiel. Also setzte ich mich und begann zu schreiben und die richtige Kopierfunktion zu testen.
Sag nein zu Null
Die Gleichheitsprüfung für Null wird häufig verwendet. Bei der Übersetzung von Haxe in cpp führt ein Objekt, das einen unbestimmten Wert zulässt, zu unnötigen Kosten, die nicht entstehen, wenn der Compiler davon ausgehen kann, dass das Objekt niemals null sein wird. Dies gilt insbesondere für Basistypen wie
Int
- Haxe, die die Gültigkeit eines undefinierten Werts für sie im statischen Zielsystem durch ihr „Packen“ implementieren. Dies tritt nicht nur für Variablen auf, die explizit als null deklariert sind (
var myVar:Null<Int>
), sondern auch für Dinge wie
?myParam:Int
(
?myParam:Int
). Darüber hinaus verursachen die Nullprüfungen selbst unnötigen Abfall.
Ich konnte einige dieser Probleme beheben, indem ich mir den Code ansah und über Alternativen nachdachte. Kann ich eine einfachere Überprüfung durchführen, die immer dann zutrifft, wenn das Objekt null ist? Kann ich null viel früher in der Kette von Funktionsaufrufen abfangen und ein einfaches Integer- oder Boolesches Flag an untergeordnete Aufrufe weitergeben? Kann ich alles so strukturieren, dass der Wert
niemals null wird? Usw. Wir können Nullprüfungen und nullwertfähige Werte nicht vollständig eliminieren, aber es hat mir sehr geholfen, sie aus Funktionen zu entfernen.
5. Downloadzeit
Auf PSVita hatten wir besonders ernsthafte Probleme mit der Ladezeit einiger Szenen. Bei der Profilerstellung stellte sich heraus, dass die Gründe hauptsächlich auf Textrasterung, unnötiges Software-Rendering, kostspieliges Rendern von Schaltflächen und andere Dinge zurückzuführen sind.
Text
HaxeFlixel basiert auf
OpenFL , das ein fantastisches und zuverlässiges TextField bietet. Aber ich habe FlxText-Objekte unvollständig verwendet - FlxText-Objekte haben ein internes OpenFL-Textfeld, das gerastert ist. Es stellte sich jedoch heraus, dass ich die meisten dieser komplexen Textfunktionen nicht benötigte, aber aufgrund der dummen Art, mein UI-System einzurichten, mussten die Textfelder gerendert werden, bevor alle anderen Objekte gefunden wurden. Dies führte beispielsweise beim Laden eines Popup-Fensters zu kleinen, aber spürbaren Sprüngen.
Hier habe ich drei Korrekturen vorgenommen: Erstens habe ich so viel Text wie möglich durch Raster-Schriftarten ersetzt. Flixel bietet integrierte Unterstützung für verschiedene Raster-Schriftformate, einschließlich
AngelCodes BMFont , wodurch die Arbeit mit Unicode, Stil und Kerning
vereinfacht wird. Die Rastertext- API unterscheidet sich jedoch geringfügig von der Nur-Text-API, sodass ich eine kleine Wrapper-Klasse schreiben musste vereinfachen Sie den Übergang. (Ich gab ihm einen passenden Namen
FlxUITextHack
).
Dies verbesserte die Arbeit geringfügig - Bitmap-Schriftarten werden sehr schnell gerendert -, erhöhte jedoch die Komplexität geringfügig: Ich musste speziell separate Zeichensätze vorbereiten und je nach Gebietsschema Schaltlogik hinzufügen, anstatt nur ein Textfeld einzurichten, das die gesamte Arbeit erledigte.
Die zweite Korrektur bestand darin, ein neues UI-Objekt zu erstellen, das ein einfacher
Platzhalter für den Text war, aber dieselben öffentlichen Eigenschaften wie der Text hatte. Ich habe es "Textbereich" genannt und eine neue Klasse dafür in meiner UI-Bibliothek erstellt, damit mein UI-System diese Textbereiche wie echte Textfelder verwenden kann, aber es wird nichts gerendert, bis es die Größe und Position für alles andere berechnet. Als meine Szene vorbereitet war, begann ich, diese Textbereiche durch echte Textfelder (oder Textfelder von Bitmap-Schriftarten) zu ersetzen.
Die dritte Korrektur betraf die Wahrnehmung. Wenn es auch innerhalb einer halben Sekunde Pausen zwischen Eingabe und Reaktion gibt, nimmt der Spieler dies als Bremsen wahr. Daher habe ich versucht, alle Szenen zu finden, in denen die Eingabe bis zum nächsten Übergang verzögert ist, und entweder eine durchscheinende Ebene mit dem Wort "Laden ..." oder nur eine Ebene ohne Text hinzugefügt. Eine solche einfache Korrektur hat die
Wahrnehmung der Reaktionsfähigkeit des Spiels erheblich verbessert, da unmittelbar nach dem Berühren des Steuerelements etwas passiert, selbst wenn die Anzeige des Menüs einige Zeit in Anspruch nimmt.
Software-Rendering
Die meisten Menüs verwenden eine Kombination aus Software-Skalierung und 9-Slice-Compositing. Dies geschah, weil es in der PC-Version eine auflösungsunabhängige Benutzeroberfläche gab, die mit einem Seitenverhältnis von 4: 3 und 16: 9 arbeiten konnte, entsprechend skaliert. Bei PSVita kennen wir
die Auflösung bereits
, dh wir benötigen nicht alle diese hochauflösenden Ressourcen und Echtzeit-Skalierungsalgorithmen. Wir können Ressourcen einfach auf die genaue Auflösung vorrendern und sie auf dem Bildschirm platzieren.
Zuerst habe ich das UI-Markup für Vita-Bedingungen eingegeben, bei dem das Spiel auf die Verwendung paralleler Ressourcen umgestellt wurde. Dann musste ich diese Ressourcen erstellen, die für eine Berechtigung vorbereitet waren. Der
HaxeFlixel-Debugger hat sich hier als sehr nützlich erwiesen. Ich habe mein Skript hinzugefügt, damit der Raster-Cache einfach auf die Festplatte
geleert wird. Dann habe ich eine spezielle Build-Konfiguration für Windows erstellt, die die Berechtigung für Vita simuliert, alle Spielmenüs der Reihe nach geöffnet, zum Debugger gewechselt und den Exportbefehl für die skalierten Versionen der Ressourcen als vorgefertigte PNGs gestartet. Dann habe ich sie einfach umbenannt und als Ressourcen für Vita verwendet.
Rendern von Schaltflächen
Mein UI-System hatte ein echtes Problem mit Schaltflächen - als sie erstellt wurden, haben die Schaltflächen den Standardressourcensatz gerendert, und einen Moment später haben sie die Größe des UI-Startcodes geändert (und erneut gerendert), und manchmal sogar das
dritte Mal, bevor die gesamte Benutzeroberfläche geladen wurde . Ich habe dieses Problem gelöst, indem ich Optionen hinzugefügt habe, die das Rendern von Schaltflächen bis zur letzten Stufe verzögerten.
Optionaler Textscan
Das Magazin wurde besonders langsam geladen. Zuerst dachte ich, das Problem liege bei den Textfeldern, aber nein. Der Zeitschriftentext könnte Links zu anderen Seiten enthalten, die durch Sonderzeichen im Rohtext selbst gekennzeichnet sind. Diese Zeichen wurden später ausgeschnitten und zur Berechnung der Position des Links verwendet.
Es stellte sich heraus. dass ich
jedes Textfeld gescannt habe, um diese Zeichen zu finden und durch korrekt formatierte Links zu ersetzen, ohne vorher zu prüfen, ob dieses Textfeld ein Sonderzeichen enthält! Schlimmer noch, je nach Design wurden Links
nur auf der Inhaltsseite verwendet, aber ich habe sie in jedem Textfeld auf jeder Seite überprüft.
Ich habe es geschafft, all diese Überprüfungen mit dem if-Konstrukt des Formulars "Verwendet dieses Textfeld überhaupt Links" zu umgehen. Die Antwort auf diese Frage war normalerweise nein. Schließlich stellte sich heraus, dass die Seite, deren Laden am längsten dauerte, die Indexseite war. Da es sich im Journalmenü nie ändert, warum zwischenspeichern wir es nicht?
6. Speicherprofilerstellung
Geschwindigkeit ist nicht nur die CPU. Speicher kann auch ein Problem sein, insbesondere auf schwachen Plattformen wie Vita. Selbst wenn Sie es geschafft haben, den letzten Speicherverlust zu beseitigen, haben Sie möglicherweise immer noch Probleme mit der Verwendung des Sägezahnspeichers in einer Speicherbereinigungsumgebung.Was ist Sägezahnspeicher? Der Garbage Collector funktioniert wie folgt: Daten und Objekte, die Sie nicht verwenden, sammeln sich im Laufe der Zeit an und werden regelmäßig gelöscht. Sie haben jedoch keine klare Kontrolle darüber, wann dies geschieht, sodass das Diagramm zur Speichernutzung wie eine Säge aussieht:Nimm den Müll raus
Da die Reinigung nicht sofort erfolgt, ist die Gesamtmenge an RAM, die Sie verwenden, normalerweise größer als Sie wirklich benötigen. Wenn Sie jedoch die Gesamtmenge des System-RAM überschreiten , kann eines von zwei Dingen passieren: Auf einem PC verwenden Sie wahrscheinlich nur eine Auslagerungsdatei , dh, Sie konvertieren vorübergehend einen Teil des Festplattenspeichers in virtuellen RAM. Eine Alternative in Umgebungen mit begrenztem Speicher (z. B. Konsolen) besteht darin, die Anwendung zum Absturz zu bringen, selbst wenn nicht genug von einem miserablen Bytepaar vorhanden war. Und dies wird auch dann passieren, wenn Sie diese Bytes nicht verwenden und die Garbage Collection bald in ihnen durchgeführt wird!Das Gute an Haxe ist, dass es vollständig Open Source ist, das heißt, Sie sind nicht in einer Black Box eingeschlossen, die Sie nicht reparieren können, wie dies bei Unity der Fall ist. Und das hxcpp-Backend bietet eine umfassende Speicherbereinigungsverwaltung direkt über die API!Wir haben sie verwendet, um den Speicher nach einem großen Level sofort zu löschen, um innerhalb der vorgegebenen Grenzen zu bleiben:cpp.vm.Gc.run(false); // (true/false - / )
Sie sollten ihn nicht unfreiwillig verwenden, wenn Sie nicht wissen, was Sie tun, aber es ist praktisch, dass solche Tools vorhanden sind, wenn sie benötigt werden.7. Problemumgehung durch Design
All diese Leistungsverbesserungen waren mehr als genug, um das Spiel für den PC zu optimieren, aber wir haben auch versucht, eine Version für PSVita zu veröffentlichen, und wir hatten langfristige Pläne für den Nintendo Switch, sodass wir alles vom Code bis zum Drop komprimieren mussten.Aber oft gibt es „Tunnelblick“, wenn Sie sich nur auf technische Hacks konzentrieren und vergessen, dass eine einfache Änderung des Designs die Situation erheblich verbessern kann .Beschleunigungseffekte mit hoher Geschwindigkeit
Bei 16x treten viele Effekte so schnell auf, dass der Spieler sie nicht einmal sieht. Wir haben bereits einen Trick angewendet - der Blitz von Azra wurde mit der Geschwindigkeit des Spiels einfacher und die Anzahl der Partikel für AOE-Angriffe ist geringer. Wir haben diese Technik durch Deaktivieren von Hochgeschwindigkeitsschadenzahlen und ähnlichen Tricks ergänzt.Wir haben auch festgestellt, dass die 16-fache Geschwindigkeit irgendwann langsamer sein kann als die 8-fache Geschwindigkeit, wenn sich zu viele Objekte auf dem Bildschirm befinden. Wenn also die Anzahl der Gegner auf ein bestimmtes Limit ansteigt, haben wir die Spielgeschwindigkeit automatisch auf das 8-fache oder 4-fache reduziert. In der Praxis wird der Spieler dies wahrscheinlich nur in Endless Battle 2 sehen. Dies ermöglicht eine reibungslose Leistung und ein reibungsloses Rendern, ohne die CPU zu überlasten.Wir haben auch Einschränkungen speziell für die Plattform verwendet. In Vita überspringen wir den Blitzeffekt, wenn Azra den Charakter auslöst oder beschleunigt, und verwenden andere ähnliche Tricks.Körper verstecken
Und was ist mit dem riesigen Haufen Feinde in der unteren rechten Ecke von Endless Battle 2 - es gibt buchstäblich Hunderte oder sogar Tausende von Feinden, die übereinander ziehen. Warum überspringen wir nicht einfach das Rendern derjenigen, die wir nicht einmal sehen können?Dies ist ein listiger Entwurfstrick, der eine listige Programmierung erfordert, da wir einen intelligenten Algorithmus benötigen, der versteckte Objekte definiert.Die meisten dieser Spiele werden mit dem Algorithmus des Künstlers gezeichnet. Die vorherigen Objekte in der Zeichnungsliste werden durch alles blockiert, was nach ihnen kommt.Durch Umkehren der Reihenfolge des Renderns des Künstleralgorithmus können Sie eine „Titelkarte“ erstellen und herausfinden, was ausgeblendet werden soll. Ich habe eine gefälschte "Leinwand" mit 8 Ebenen der "Dunkelheit" (nur eine zweidimensionale Anordnung von Bytes) mit einer viel niedrigeren Auflösung als ein echtes Schlachtfeld erstellt. Ab dem Ende der Rendering-Liste nehmen wir den Begrenzungsrahmen jedes Objekts und „zeichnen“ ihn auf die Leinwand, wobei wir die „Dunkelheit“ des Punkts für jedes „Pixel“, das von dem Begrenzungsrahmen mit niedriger Auflösung abgedeckt wird, um 1 erhöhen. Gleichzeitig lesen wir die durchschnittliche "Dunkelheit" des Bereichs, in dem wir zeichnen werden. Tatsächlich sagen wir voraus, wie viele Neuzeichnungen jedes Objekt mit einem echten Zeichenaufruf erfahren wird.Wenn die vorhergesagte Anzahl von Neuzeichnungen hoch genug ist, markiere ich den Feind als "begraben" mit zwei Schwellenwerten - vollständig begraben, dh vollständig unsichtbar oder teilweise begraben, dh er wird gezogen, ohne jedoch einen Gesundheitsbalken zu erstellen.(Übrigens ist hier die Funktion zum Überprüfen von Neuzeichnungen.)Damit dies korrekt funktioniert, müssen Sie die Auflösung der ausgeblendeten Karte korrekt konfigurieren. Wenn es zu groß ist, müssen wir eine zusätzliche Reihe vereinfachter Zeichenaufrufe ausführen. Wenn es zu klein ist, werden wir Objekte zu aggressiv ausblenden und visuelle Fehler erhalten. Wenn Sie die Karte richtig auswählen, ist der Effekt kaum spürbar, aber die Geschwindigkeitssteigerung ist sehr spürbar - es gibt keine Möglichkeit, etwas schneller zu zeichnen, als es überhaupt nicht zu zeichnen !Bessere Vorspannung als Bremsen
Mitten in den Kämpfen bemerkte ich häufiges Bremsen, was sicher durch eine Pause in der Müllabfuhr verursacht wurde. Die Profilerstellung hat jedoch gezeigt, dass dies nicht der Fall ist. Weitere Tests ergaben, dass dies zu Beginn der Spawn-Welle von Feinden geschieht, und später stellte ich fest, dass dies nur dann geschieht, wenn es sich um eine Welle von Feinden handelt, die zuvor nicht existierte. Offensichtlich hat ein feindlicher Konfigurationscode das Problem verursacht, und natürlich wurde beim Erstellen von Profilen in den Grafikeinstellungen eine „heiße“ Funktion gefunden. Ich fing an, an einem komplexen Multithread-Download-Setup zu arbeiten, aber dann wurde mir klar, dass ich einfach alle feindlichen Grafikladevorgänge in das Battle Preload einbauen konnte. Unabhängig davon waren dies sehr kleine Downloads, selbst auf den langsamsten Plattformen, die weniger als eine Sekunde zur gesamten Ladezeit des Kampfes beitrugen, aber sie vermieden ein sehr merkliches Bremsen während des Spiels.Wir behalten uns den Vorrat für später vor
Wenn Sie in einer Umgebung mit begrenztem Speicher arbeiten, können Sie den alten Trick unserer Branche verwenden - einfach so ein großes Stück Speicher zuweisen und es dann bis zum Ende des Projekts vergessen. Wenn Sie am Ende des Projekts das gesamte verfügbare Speicherbudget verschwendet haben, können Sie dank dieses „Notgroschen“ gerettet werden.Wir befanden uns in einer solchen Situation - wir brauchten nur ein Dutzend Bytes, um die Assembly für PSVita zu speichern, aber zur Hölle - wir haben diesen Trick vergessen und sind deshalb hängen geblieben! Die einzigen verbleibenden Optionen waren Wochen verzweifelter und schmerzhafter Code-Operationen!Aber warte einen Moment! Eine meiner (erfolglosen) Optimierungen bestand darin, so viele Ressourcen wie möglich und dauerhaft zu ladenSpeichern im Speicher, da ich fälschlicherweise angenommen habe, dass eine große Ladezeit durch das Lesen von Ressourcen während der Ausführung des Programms verursacht wurde. Es stellte sich heraus, dass dies nicht der Fall war, sodass fast alle diese zusätzlichen Anforderungen für das Vorladen und die ewige Speicherung vollständig entfernt werden konnten und ich immer noch freien Speicher hatte!Dinge loswerden, die wir nicht benutzen
Bei der Arbeit am Build für PSVita haben wir besonders deutlich gemacht, dass es eine Reihe von Dingen gibt, die wir einfach nicht brauchen. Aufgrund der geringen Auflösung waren der Quellgrafikmodus und der HD-Grafikmodus nicht zu unterscheiden. Daher haben wir für alle Sprites die Originalgrafiken verwendet. Es ist uns auch gelungen, die Funktion zum Ersetzen der Palette mithilfe eines speziellen Pixel-Shaders zu verbessern (früher haben wir die Funktion des Programm-Renderings verwendet).Ein weiteres Beispiel war die Kampfkarte selbst - auf dem PC und den Heimkonsolen haben wir ein paar Kachelkarten übereinander gestapelt, um eine mehrschichtige Karte zu erstellen. Da sich die Karte jedoch nie ändert, können wir auf Vita einfach alles zu einem fertigen Bild zusammenbacken, damit es in einem Draw Call aufgerufen wird.Zusätzlich zu den zusätzlichen Ressourcen hatte das Spiel viele zusätzliche Anrufe, zum Beispiel, dass Verteidiger und Feinde in jedem Frame ein Regenerationssignal sendeten, auch wenn sie nicht in der Lage waren, sich zu regenerieren . Wenn die Benutzeroberfläche für eine solche Kreatur geöffnet war, wurde sie in jedem Frame neu gezeichnet .Es gibt ein halbes Dutzend anderer Beispiele für kleine Algorithmen, die etwas innerhalb einer „heißen“ Funktion berechnen, aber nirgendwo Ergebnisse zurückgeben. Normalerweise waren dies die Ergebnisse der Erstellung der Struktur in den frühen Entwicklungsstadien, daher haben wir sie einfach ausgeschnitten.NaNopocalypse
Dieser Fall war lustig. Der Profiler berichtete, dass die Berechnung der Winkel viel Zeit in Anspruch nimmt. Hier ist der generierte Haxe C ++ - Code im Profiler:Dies ist eine dieser Funktionen, die Werte wie annehmen -90
und in konvertieren 270
. Manchmal erhalten Sie Werte wie -724
, die in wenigen Zyklen auf reduziert werden 4
.Aus irgendeinem Grund wurde ein Wert an diese Funktion übergeben -2147483648
.Lassen Sie uns die Berechnungen durchführen. Wenn wir in jedem Zyklus 360 zu -2147483648 addieren, dauert es ungefähr 5.965.233 Iterationen, bis er größer als 0 wird und den Zyklus abschließt. Übrigens wurde dieser Zyklus mit jedem Update (nicht in jedem Frame - in jedem Update !) Durchgeführt - jedes Mal, wenn das Projektil (oder etwas anderes) seinen Winkel änderte.Natürlich war es meine Schuld, weil ich einen Wert übergeben habe NaN
- einen speziellen Wert, der "Keine Zahl" (keine Zahl) bedeutet, was normalerweise einen Fehler signalisiert, der zuvor im Code aufgetreten ist. Wenn Sie es auf eine Ganzzahl bringen, ohne es vorher zu überprüfen, passieren solche seltsamen Dinge.Als vorübergehende Korrektur habe ich einen Scheck hinzugefügtMath.isNan()
, die den Winkel zurücksetzen, wenn ein solches (eher seltenes, aber unvermeidliches) Ereignis eintritt. Gleichzeitig suchte ich weiter nach der Grundursache des Fehlers, fand sie und die Verzögerung verschwand sofort. Es stellt sich heraus, dass Sie eine große Geschwindigkeitssteigerung erzielen können, wenn Sie nicht 6 Millionen bedeutungslose Iterationen durchführen!(Ein Fix für diesen Fehler wurde in HaxeFlixel selbst eingefügt .)Überlisten Sie sich nicht
Sowohl OpenFL als auch HaxeFlixel basieren auf Ressourcen-Caching. Dies bedeutet, dass beim Laden einer Ressource beim nächsten Empfang diese Ressource aus dem Cache entnommen und nicht erneut von der Festplatte geladen wird. Dieses Verhalten kann überschrieben werden und ist manchmal sinnvoll.Ich geriet jedoch in einige seltsame, weit hergeholte Dinge: Ich lud die Ressource herunter und forderte das System ausdrücklich auf, die Ergebnisse nicht zwischenzuspeichern, da ich mir völlig sicher war, was ich tat, und keinen Speicher im Cache „verschwenden“ wollte. Jahre später haben mich diese „intelligenten“ Aufrufe dazu gebracht, immer wieder dieselbe Ressource zu laden, das Spiel zu verlangsamen und wertvollen Speicher zu verschwenden, den ich durch das Verlassen des Caches „gespeichert“ habe.8. Außerdem lohnt es sich möglicherweise nicht, Levels wie Endless Battle 2 zu absolvieren
Ja, es ist großartig, dass wir all diese kleinen Tricks implementiert haben, um die Geschwindigkeit zu erhöhen. Ehrlich gesagt haben wir die meisten von ihnen erst bemerkt, als wir damit begannen, das Spiel auf weniger leistungsfähige Systeme zu portieren, als die Probleme auf einigen Ebenen völlig unerträglich wurden. Ich bin froh, dass wir es am Ende geschafft haben, die Geschwindigkeit zu erhöhen, aber ich denke, dass das pathologische Level-Design auch vermieden werden sollte. Endless Battle 2 hat das System zu stark belastet, insbesondere im Vergleich zu allen anderen Levels des Spiels .Selbst nach all diesen Änderungen kann die PSVita-Version das ursprüngliche Endless 2-Design nicht bewältigen, und ich wollte die Geschwindigkeit der Basismodelle XB1 und PS4 nicht riskieren. Deshalb habe ich die Balance für die Konsolenversionen von Endless 2 geändert. Ich habe die Anzahl der Gegner verringert, aber deren Eigenschaften erhöht so dass das Level ungefähr die gleiche Schwierigkeit hat. Außerdem haben wir bei PSVita die Anzahl der Wellen auf einhundert begrenzt, um das Risiko eines Speicherausfalls zu vermeiden, aber keine Einschränkungen für PS4 und XB1 hinzugefügt. Dank dessen ist das Erreichen der Ausdauerleistung auf allen Konsolen immer noch gleich schwierig. In der PC-Version blieb das Design des Endless Batlte 2 Level unverändert.All dies war eine Lektion für uns, die wir bei der Erstellung von Defender's Quest II berücksichtigen werden - wir werden sehr aufmerksam auf Levels ohne Obergrenze für die Anzahl der Feinde auf dem Bildschirm sein! Natürlich sind die "endlosen" Missionen für Tower Defense-Fans sehr attraktiv, daher werde ich sie nicht vollständig los, aber was ist mit den Levels mit Kontrollpunkten, an denen der Spieler alles auf dem Bildschirm zerstören MUSS, bevor er zu den nächsten Wellen übergeht? Dies ermöglicht es uns nicht nur, die Anzahl der Gegner auf dem Bildschirm zu begrenzen, sondern auch das Speichern in der Mitte des Levels zu realisieren, ohne den Zustand der verrückten Suppe von Objekten in einem intensiven Kampf zu serialisieren. Es reicht aus, nur die Koordinaten der Verteidiger zu speichern, Level zu erhöhen usw.9. Gedanken zum Schluss
Die Spieleleistung ist ein komplexes Thema, da die Spieler oft nicht verstehen, was es ist, und wir sollten von ihnen kein solches Verständnis erwarten. Aber ich hoffe, dass dieser Artikel ein wenig für Sie klargestellt hat, wie alles im Inneren aussieht, und Sie haben mehr darüber gelernt, wie Design, technische Kompromisse und einfach dumme Entscheidungen Spiele verlangsamen.Das Fazit ist, dass selbst in einem Spiel mit einem guten Design, das von einem talentierten Team entwickelt wurde, solche kleinen „rostigen“ Codefragmente absolut überall zu finden sind . In der Praxis wirkt sich jedoch nur ein kleiner Teil davon tatsächlich auf die Leistung aus. Die Fähigkeit, sie zu erkennen und zu beseitigen, ist Kunst und Wissenschaft gleichermaßen.Ich bin froh, dass wir all diese Vorteile bei der Entwicklung von Defender's Quest II nutzen werden. Wenn wir keinen Port für PSVita erstellt hätten, hätte ich wahrscheinlich nicht einmal die Hälfte dieser Optimierungen ausprobiert. Und selbst wenn Sie das Spiel nicht für PSVita kaufen, können Sie sich bei dieser kleinen Konsole bedanken, die die Geschwindigkeit von Defender's Quest erheblich verbessert hat.