Dies ist das zweite Tutorial für GameUtils. Hier werde ich euch näher bringen, wie das Updateing in der Engine von statten geht, was es mit den Zuständen auf sich hat und die Anwendung aus dem ersten Kapitel nach eurem neuen Kenntnisstand erweitern.
Zustände in der Engine
Neben den oben genannten Engine-Komponenten und -Ressourcen gibt es noch eine weitere wichtige Gruppe von Objekten in einem auf GameUtils basierenden Spiel. Diese Objekte speichern Zustände und werden nicht von der GameEngine, sondern vom GameLoop verwaltet. Höchst wahrscheinlich werden diese Objekte die Mehrheit in einem Spiel ausmachen, da sie alles erdenklich darstellen können, solange es geupdatet werden kann/muss.
Aus anderen Systemen (auch von GameUtils vor der Alpha 2.0) ist euch der Updatevorgang vielleicht so bekannt, dass regelmäßig eine Update-Methode auf allen Spielobjekten aufgerufen wird (wie und wo diese aufgerufen werden ist hier erstmal egal). Diese Variante ist einfach umzusetzen, hat jedoch einige Probleme, weswegen ich mich bewusst dagegen entschieden habe. Zum einen ist die Reihenfolge, in der die Objekte geupdatet werden, entscheidend. Möglicherweise hat sich z.B. vor einer Kollisionsprüfung ein anderes Objekte bereits vorher wieder weiterbewegt und kollidiert deshalb nicht, oder umgekehrt. Deshalb kann der Update-Vorgang auch nicht parallelisiert werden, sondern muss in einem einzigen Thread stattfinden, was auf modernen Computern Leistungsverschwendung ist. Wenn man nun noch das Rendering hinzuzieht, so werden die Probleme noch größer. Laufen Updateing und Rendering nebeneinander, dann ist es sehr wahrscheinlich, dass während dem REndering bestimmte Objekte bereits geupdatet wurden, andere jedoch nicht, wodurch sich die auf dem Bildschirm angezeigten Objekte in unterschiedlichen Update-Zuständen befinden. Einzige Möglichkeit, das effektiv zu lösen, ist Updateing und Rendering nicht nebeneinander, sondern nacheinander laufen zu lassen, und damit haben wir nun jegliche Form von Multithreading abgeschafft.
Da diese Lösung also nicht akzeptabel für eine High-Performance-Engine war, musst etwas neues her, etwas, dass sich von der althergebrachten Methode deutlich unterschied. Ich habe mich für die sogenannte "Double Buffer"-Methode entschieden. Statt einem Objekt, dass sich regelmäßig updatet, existieren jetzt zwei Objekte (zwei Zustände) nebeneinander, zwar unzertrennbar verknüpft, aber dennoch nicht direkt voneinander abhängig. Der Trick besteht nun darin, dass immer einer dieser Zustände geupdatet wird (und zwar auf Basis des anderen Zustandes), während der andere gezeichnet wird. So wird der Zustand, auf den man während dem Update zugreift, nicht verändert, wodurch die Reihenfolge der Updates irrelevant werden. Außerdem kann der momentan unveränderte Zustand zum Rendern verwendet werden und garantiert, dass alle Objekte im gleichen Updatezustand gezeichnet werden. Updateing und Rendering lassen sich also auf Kosten des doppelten Speicherverbrauchs perfekt parallelisieren, das Updating soger soweit, dass die einzelnen Objekte in verschiedenen Threads geupdatet werden können.
Keine Sorge, wenn ihr jetzt nicht gleich alles verstanden habt, anhand des Codes wird es nun deutlich werden. Es ist nicht so schwer, wie es sich anhört, nur ungewohnt (auch ich muss mich immer noch daran gewöhnen, falls euch das beruhigt).
Spoiler anzeigen
Der Update-Zyklus
Bevor wir an den Code gehen, gibts noch einen kleinen Exkurs dazu, wie die Engine Updateing und Rendering koordiniert.
Gleich zu Anfang: ein Objekt kann selbstverständlich noch nicht gerendert werden, wenn es noch nie geupdatet wurde. Die Engine trägt dafür Sorge, dass ein Objekt immer mindestens einmal geupdatet wurde, bevor es das erste mal gerendert wird, ihr braucht euch darum also nicht kümmern.
Updateing und Rendering sind in GameUtils so synchronisiert, dass es immer genauso viele Update-Zyklen wie Render-Zyklen gibt, das bedeutet, sie blockieren sich gegenseitig. Im Normalfall wird das Rendering das Updateing begrenzen, entweder durch das FPS-Limit oder weil das Rendern doch wesentlich länger dauert, weil es nicht so stark synchronisiert werden kann. Das ist aber nicht schlimm, denn die Engine bietet Möglichkeiten, auf Usereingaben zu reagieren unabhängig vom normalen Updateing. Grund für dieses Verhalten ist, dass nur so die ganz zu Beginn genannten Probleme beseitigt werden können.
Ich will nicht zu sehr ins technische abdriften, das Grundprinzip ist dieses:
Quelle: altdevblogaday.com/2011/07/03/threading-and-your-game-loop/
Wir erweitern unser Spiel
Jetzt werden wir das neue Wissen an unserer Testanwendung ausprobieren. Ziel ist es, einen einfachen Zähler korrekt in jedem Update zu inkrementieren. Leider können wir unsere Ergebnisse noch nicht anzeigen, aber spätestens nach dem nächsten Tutorial wird auch das dann kein Problem mehr sein.
Also worauf warten wir noch? Als erstes brauchen wir unsere Zustands-Klasse (ich hab sie hier einfach mal CounterState genannt):
Das war auch schon die ganze Magie. Wir nehmen im Update den alten Wert aus dem anderen Zustand, addieren 1 und weisen es dem aktuellen Counter zu.
Zur Veranschaulichung, dass auch wirklich das gewünschte passiert, habe ich noch eine Debug-Ausgabe eingebaut.
Als nächstes brächten wir eigentlich unseren Renderer. Da wir diesen noch nicht benutzen können, lassen wir ihn aus und geben dann später einfach Nothing zurück. Die Engine wird dies akzeptieren und dieses Objekt als nicht darstellbar behandeln (das macht für euch im Moment überhaupt keinen Unterschied).
Jetzt fehlt also nur noch der RegistrationContext. Dort können wir es ganz einfach halten, als Renderer geben wir, wie schon angekündigt, null zurück, in CreateBuffer können wir einfach immer einen neuen CounterState zurückgeben:
War doch gar nicht so schwer.
Als letztes müssen wir unsere Main-Methode noch um eine Zeile erweitern, damit auch was passiert:
Hab ihr alles richtig gemacht, dann erscheinen in eurer Console nun Zahlen von 1 bis n. Herzlichen Glückwunsch, ihr habt diesen Teil des Tutorials erfolgreich gemeistert.
Weiter: Tutorial - Kapitel 3: Factory-Ressourcen und das Rendering
Zustände in der Engine
Neben den oben genannten Engine-Komponenten und -Ressourcen gibt es noch eine weitere wichtige Gruppe von Objekten in einem auf GameUtils basierenden Spiel. Diese Objekte speichern Zustände und werden nicht von der GameEngine, sondern vom GameLoop verwaltet. Höchst wahrscheinlich werden diese Objekte die Mehrheit in einem Spiel ausmachen, da sie alles erdenklich darstellen können, solange es geupdatet werden kann/muss.
Aus anderen Systemen (auch von GameUtils vor der Alpha 2.0) ist euch der Updatevorgang vielleicht so bekannt, dass regelmäßig eine Update-Methode auf allen Spielobjekten aufgerufen wird (wie und wo diese aufgerufen werden ist hier erstmal egal). Diese Variante ist einfach umzusetzen, hat jedoch einige Probleme, weswegen ich mich bewusst dagegen entschieden habe. Zum einen ist die Reihenfolge, in der die Objekte geupdatet werden, entscheidend. Möglicherweise hat sich z.B. vor einer Kollisionsprüfung ein anderes Objekte bereits vorher wieder weiterbewegt und kollidiert deshalb nicht, oder umgekehrt. Deshalb kann der Update-Vorgang auch nicht parallelisiert werden, sondern muss in einem einzigen Thread stattfinden, was auf modernen Computern Leistungsverschwendung ist. Wenn man nun noch das Rendering hinzuzieht, so werden die Probleme noch größer. Laufen Updateing und Rendering nebeneinander, dann ist es sehr wahrscheinlich, dass während dem REndering bestimmte Objekte bereits geupdatet wurden, andere jedoch nicht, wodurch sich die auf dem Bildschirm angezeigten Objekte in unterschiedlichen Update-Zuständen befinden. Einzige Möglichkeit, das effektiv zu lösen, ist Updateing und Rendering nicht nebeneinander, sondern nacheinander laufen zu lassen, und damit haben wir nun jegliche Form von Multithreading abgeschafft.
Da diese Lösung also nicht akzeptabel für eine High-Performance-Engine war, musst etwas neues her, etwas, dass sich von der althergebrachten Methode deutlich unterschied. Ich habe mich für die sogenannte "Double Buffer"-Methode entschieden. Statt einem Objekt, dass sich regelmäßig updatet, existieren jetzt zwei Objekte (zwei Zustände) nebeneinander, zwar unzertrennbar verknüpft, aber dennoch nicht direkt voneinander abhängig. Der Trick besteht nun darin, dass immer einer dieser Zustände geupdatet wird (und zwar auf Basis des anderen Zustandes), während der andere gezeichnet wird. So wird der Zustand, auf den man während dem Update zugreift, nicht verändert, wodurch die Reihenfolge der Updates irrelevant werden. Außerdem kann der momentan unveränderte Zustand zum Rendern verwendet werden und garantiert, dass alle Objekte im gleichen Updatezustand gezeichnet werden. Updateing und Rendering lassen sich also auf Kosten des doppelten Speicherverbrauchs perfekt parallelisieren, das Updating soger soweit, dass die einzelnen Objekte in verschiedenen Threads geupdatet werden können.
Keine Sorge, wenn ihr jetzt nicht gleich alles verstanden habt, anhand des Codes wird es nun deutlich werden. Es ist nicht so schwer, wie es sich anhört, nur ungewohnt (auch ich muss mich immer noch daran gewöhnen, falls euch das beruhigt).
Um diese mehr oder weniger komplexe Struktur zu erstellen, benötigt ihr zuerst einmal einen
Euch wird vermutlich sofort auffallen, dass die Klasse generisch ist. Das ist ein weiterer Vorteil von GameUtils, ich habe einigen Aufwand betrieben, damit ihr immer genau angeben könnt, was ihr reingebt, und dann auch exakt dass wieder rausbekommt, es besteht keine Notwendigkeit mit Basisklassen, Interfaces und viel Typecasting zu arbeiten.
In diesem Fall gibt das Typargument den Typen eurer Zustands-Klasse an, welche
CurrentState: gibt den aktuellen Zustand an. Das ist derjenige Zustand, der gerade nicht geupdatet wird, also der, den andere Objekte zum Updaten verwenden sollten, wenn sie denn überhaupt von anderen Objekten abhängig sind. Der Typ ist der als Typparameter angegebene.
Renderer: der Renderer für dieses Objekt. Dieser ist vom Typ
CreateBuffer: diese Funktion ist ebenfalls protected und erstellt einen Zustandspuffer. Bei korrekter Verwendung wird sie von der Engine genau zweimal aufgerufen. Der Rückgabetyp entspricht dem Typargument.
CurrentStateChaged: ein Ereignis, das nur der RegistrationContext selbst empfangen kann und ausgelöst wird, wenn der aktuelle Status geändert wurde (dies geschieht einmal pro Update-Zyklus).
Das Zustand-Interface
Das Interface um die Zustände darzustellen, ist, wie bereits erwähnt,
Dies ist ein mehr oder weniger interessantes Interface, man beachte den Namen des Typparameters. Auch wenn es möglich ist, hier etwas anderes anzugeben, müsst ihr laut Engine-Constraint hier den eigenen Typn angeben, ansonsten wird nichts funktionieren. Bsp.:
Das Interface besitzt genau eine Methode, nämlich
Die Methode wird immer aufgerufen, wenn der Zustand geupdatet werden muss. Der erste Parameter ist dabei der alte Zustand, auf den ausschließlich lesend zugegriffen werden darf. Der zweite Parameter gibt die Zeit seit dem letztem Update an (wichtig: dies ist die Zeit seit dem letzten Update des gesamten Objektes, nicht seit dem letzten Update dieses einen Buffers).
Das Render-Interface
Obwohl jedes Objekt zwei Zustände speichert, besitzt es nur einen einzigen Renderer, verfügbar gemacht durch das
Die Member
Im Moment wichtig ist die
RegistrationContext
. Das ist eine abstrakte Klasse, die der Engine erlaubt, für euch diese oben genannten Beziehungen zu knüpfen.Euch wird vermutlich sofort auffallen, dass die Klasse generisch ist. Das ist ein weiterer Vorteil von GameUtils, ich habe einigen Aufwand betrieben, damit ihr immer genau angeben könnt, was ihr reingebt, und dann auch exakt dass wieder rausbekommt, es besteht keine Notwendigkeit mit Basisklassen, Interfaces und viel Typecasting zu arbeiten.
In diesem Fall gibt das Typargument den Typen eurer Zustands-Klasse an, welche
IBufferedState
implementieren muss. Doch bevor wir dazu kommen ersteinmal die Member im Überblick:CurrentState: gibt den aktuellen Zustand an. Das ist derjenige Zustand, der gerade nicht geupdatet wird, also der, den andere Objekte zum Updaten verwenden sollten, wenn sie denn überhaupt von anderen Objekten abhängig sind. Der Typ ist der als Typparameter angegebene.
Renderer: der Renderer für dieses Objekt. Dieser ist vom Typ
IStateRenderer<TState>
, ist also vom Typargument abhängig. Auch zu diesem Interface werde ich gleich noch kommen. Die Eigenschaft ist protected, da sie nur von der Engine intern verwendet wird.CreateBuffer: diese Funktion ist ebenfalls protected und erstellt einen Zustandspuffer. Bei korrekter Verwendung wird sie von der Engine genau zweimal aufgerufen. Der Rückgabetyp entspricht dem Typargument.
CurrentStateChaged: ein Ereignis, das nur der RegistrationContext selbst empfangen kann und ausgelöst wird, wenn der aktuelle Status geändert wurde (dies geschieht einmal pro Update-Zyklus).
Das Zustand-Interface
Das Interface um die Zustände darzustellen, ist, wie bereits erwähnt,
IBufferedState
:Dies ist ein mehr oder weniger interessantes Interface, man beachte den Namen des Typparameters. Auch wenn es möglich ist, hier etwas anderes anzugeben, müsst ihr laut Engine-Constraint hier den eigenen Typn angeben, ansonsten wird nichts funktionieren. Bsp.:
Das Interface besitzt genau eine Methode, nämlich
Update(TSelf oldState, TimeSpan elapsed)
.Die Methode wird immer aufgerufen, wenn der Zustand geupdatet werden muss. Der erste Parameter ist dabei der alte Zustand, auf den ausschließlich lesend zugegriffen werden darf. Der zweite Parameter gibt die Zeit seit dem letztem Update an (wichtig: dies ist die Zeit seit dem letzten Update des gesamten Objektes, nicht seit dem letzten Update dieses einen Buffers).
Das Render-Interface
Obwohl jedes Objekt zwei Zustände speichert, besitzt es nur einen einzigen Renderer, verfügbar gemacht durch das
IStateRenderer
-Interfce.Die Member
DepthPosition
und DepthPositionChanged
sind für Tiefeninformationen da, das interessiert uns aber im Moment noch nicht, weshalb ich sie einfach überspringen und später leer implementieren werde.Im Moment wichtig ist die
Render(TState state, Renderer renderer)
-Method, mit der man später Dinge auf den Bildschirm bekommt. Der erste Parameter ist der Zustand, der gerendert werden soll. Alles, was gerendert werden soll (mit Ausnahme vielleicht der Dinge, die immer unverändert dargestellt werden sollen) sollte nur auf diesem Zustand basieren, auf nichts anderem, weil nur so die Integrität gewährleistet werden kann. Als zweites bekommt ihr hier den Renderer, den ihr zum rendern verwenden könnt/müsst. Dies ist der Renderer, den ihr ganz am Anfang, siehe Kapitel 1, erstellt habt. Der Update-Zyklus
Bevor wir an den Code gehen, gibts noch einen kleinen Exkurs dazu, wie die Engine Updateing und Rendering koordiniert.
Gleich zu Anfang: ein Objekt kann selbstverständlich noch nicht gerendert werden, wenn es noch nie geupdatet wurde. Die Engine trägt dafür Sorge, dass ein Objekt immer mindestens einmal geupdatet wurde, bevor es das erste mal gerendert wird, ihr braucht euch darum also nicht kümmern.
Updateing und Rendering sind in GameUtils so synchronisiert, dass es immer genauso viele Update-Zyklen wie Render-Zyklen gibt, das bedeutet, sie blockieren sich gegenseitig. Im Normalfall wird das Rendering das Updateing begrenzen, entweder durch das FPS-Limit oder weil das Rendern doch wesentlich länger dauert, weil es nicht so stark synchronisiert werden kann. Das ist aber nicht schlimm, denn die Engine bietet Möglichkeiten, auf Usereingaben zu reagieren unabhängig vom normalen Updateing. Grund für dieses Verhalten ist, dass nur so die ganz zu Beginn genannten Probleme beseitigt werden können.
Ich will nicht zu sehr ins technische abdriften, das Grundprinzip ist dieses:
Quelle: altdevblogaday.com/2011/07/03/threading-and-your-game-loop/
Wir erweitern unser Spiel
Jetzt werden wir das neue Wissen an unserer Testanwendung ausprobieren. Ziel ist es, einen einfachen Zähler korrekt in jedem Update zu inkrementieren. Leider können wir unsere Ergebnisse noch nicht anzeigen, aber spätestens nach dem nächsten Tutorial wird auch das dann kein Problem mehr sein.
Also worauf warten wir noch? Als erstes brauchen wir unsere Zustands-Klasse (ich hab sie hier einfach mal CounterState genannt):
VB.NET-Quellcode
- Public Class CounterState : Implements IBufferedState(Of CounterState)
- Private _count As Integer
- Public Property Count As Integer
- Get
- Return _count
- End Get
- Private Set(value As Integer)
- _count = value
- End Set
- End Property
- Private Sub Update(oldState As CounterState, elapsed As TimeSpan) Implements IBufferedState(Of CounterState).Update
- Me.Count = oldState.Count + 1
- Debug.Print(Me.Count.ToString())
- End Sub
- End Class
Zur Veranschaulichung, dass auch wirklich das gewünschte passiert, habe ich noch eine Debug-Ausgabe eingebaut.
Als nächstes brächten wir eigentlich unseren Renderer. Da wir diesen noch nicht benutzen können, lassen wir ihn aus und geben dann später einfach Nothing zurück. Die Engine wird dies akzeptieren und dieses Objekt als nicht darstellbar behandeln (das macht für euch im Moment überhaupt keinen Unterschied).
Jetzt fehlt also nur noch der RegistrationContext. Dort können wir es ganz einfach halten, als Renderer geben wir, wie schon angekündigt, null zurück, in CreateBuffer können wir einfach immer einen neuen CounterState zurückgeben:
VB.NET-Quellcode
- Public Class Counter : Inherits RegistrationContext(Of CounterState)
- Protected Overrides Function CreateBuffer() As CounterState
- Return New CounterState()
- End Function
- Protected Overrides ReadOnly Property Renderer As Graphics.IStateRenderer(Of CounterState)
- Get
- Return Nothing
- End Get
- End Property
- End Class
Als letztes müssen wir unsere Main-Methode noch um eine Zeile erweitern, damit auch was passiert:
C#-Quellcode
- [STAThread]
- static void Main()
- {
- Application.EnableVisualStyles();
- Application.SetCompatibleTextRenderingDefault(false);
- var window = new GameWindow();
- GameEngine.RegisterComponent(Renderer.Create<GdiRenderer>(window));
- var loop = new GameLoop();
- GameEngine.RegisterComponent(loop);
- loop.Register(new Counter()); // <--- neu
- loop.Start();
- window.FormClosing += (sender, e) => loop.Stop();
- Application.Run(window);
- }
VB.NET-Quellcode
- <STAThread()>
- Sub Main()
- Application.EnableVisualStyles()
- Application.SetCompatibleTextRenderingDefault(False)
- Dim window As New GameWindow()
- GameEngine.RegisterComponent(Renderer.Create(Of GdiRenderer)(window))
- Dim [loop] As New GameLoop()
- GameEngine.RegisterComponent([loop])
- [loop].Register(New Counter()) ' <--- neu
- [loop].Start()
- AddHandler window.FormClosing, Sub(sender, e) [loop].Stop()
- Application.Run(window)
- End Sub
Hab ihr alles richtig gemacht, dann erscheinen in eurer Console nun Zahlen von 1 bis n. Herzlichen Glückwunsch, ihr habt diesen Teil des Tutorials erfolgreich gemeistert.
Weiter: Tutorial - Kapitel 3: Factory-Ressourcen und das Rendering
Dieser Beitrag wurde bereits 1 mal editiert, zuletzt von „Artentus“ ()