Wir haben nun das Zeitalter der KI erreicht, weshalb hier weniger Lösungen und mehr das Technik Tagebuch Posts kommen.
Lösungen verlinke ich Kategorisch extra auf den weiteren Seiten des Blogs
Diesmal
dürfte das etwas mehr retro (eine Zeitreise - back in time)
sein.
Dieses Board kaufte ich vor 14 Jahren, als ich noch in der Ausbildung war. Mit
dem Programmieren kam ich bis dato schlecht voran, aber ich wollte das können.
Visual Studio 2005 war zu der Zeit aktuell und die Sprache C# programmierte ich
noch sehr anfänglich. Ziel war damals einen Roboterarm zu bauen, aus dem nie
was wurde. Dennoch wusste ich, welche Dinge ich zunächst lernen musste, damit
ich mich meinem Ziel nähern konnte. Doch bevor ich damals mit der beiliegenden Test-Software etwas anstellen konnte, musste der Bausatz zusammengelötet
werden.
Nun kam das Wochenende und ich suchte eine Lösung in C# und wurde nach langer
Suche fündig. Ich fand einen Beispielprogrammcode, wie ich auf die beiliegende
DLL zugreifen konnte und somit das Board auslesen und steuern konnte. Fast das
ganze Wochenende verbrachte ich damals mit dem Programmieren eines eigenen
Tools, dass auf C#, .NET Framework 2.0 und WindowsForms setzte.
Gegenwart Aus heutiger Sicht, war der Programmcode, den ich damals geschrieben hatte,
sehr einfach und dient nicht gerade als
Beispiel-Programm. Deshalb entschied ich mich ein neues Beispiel-Programm zu
schreiben, bevor ich das Board jetzt zeitnah bei eBay zum Verkauf anbiete.
Ein Teil davon wird neu sein
Ich fand in meinen Sicherung tatsächlich mein erstes Programm wieder, aber was ich an Programmcode entdeckte, sollte auch in der Sicherung bleiben. Deshalb kopierte ich den wesentlichen Programmcode. Für die GUI verwende ich WPF und kein WindowsForms mehr. Das Ziel ist wieder ein Tool zu haben, dass die Verbindung herstellen kann. Die Steuerung sollte das Einschalten der Ausgänge und das Lesen der Eingänge ermöglichen.
Verbindung zu K8055D.dll
Im folgenden gezeigten Programmcode aus der Klasse ComponentK8055D.cs, wird über das Attribute 'DllImport' auf die Methode zu gegriffen. Die aufgezeigten Methoden greifen auf die Grundfunktionen zu und sind daher die Wesentlichen.
Der Rest der Klasse führt die Funktionen der DLL aus und fängt Werte ab, die nicht passen. Die Methoden selbst haben entsprechen einen XML Kommentar, um beim Aufruf der Methoden zu zeigen, welche Eingaben für die Parameter gültig sind.
#region public methods
public int Open(bool sk5, bool sk6)
{
int result = 3;
result -= sk5 ? 1 : 0;
result -= sk6 ? 2 : 0;
OpenDevice(result);
return result;
}
internal void Close() => CloseDevice();
public void WriteOutputDigital(int outputNumber, bool setupOnOff)
{
outputNumber = this.MinMaxValue(outputNumber, 1, 8);
if (setupOnOff)
{
SetDigitalChannel(outputNumber);
}
else
{
ClearDigitalChannel(outputNumber);
}
}
public void WriteOutputAnalog(int outputNumber, int value)
{
outputNumber = this.MinMaxValue(outputNumber, 1, 2);
value = this.MinMaxValue(value, 0, 255);
OutputAnalogChannel(outputNumber, value);
}
public bool ReadDigitalInput(int inputNumber)
{
inputNumber = this.MinMaxValue(inputNumber, 1, 5);
return ReadDigitalChannel(inputNumber);
}
public int ReadAnalogInput(int inputNumber)
{
inputNumber = this.MinMaxValue(inputNumber, 1, 2);
return ReadAnalogChannel(inputNumber);
}
#endregion
#region private methods
private int MinMaxValue(int value, int limitMin, int limitMax)
{
if(value < limitMin)
{
value = limitMin;
}
if(value > limitMax)
{
value = limitMax;
}
return value;
}
#endregion
public void Dispose() => CloseDevice();
}
Der Rest des Programmcodes, dass die Beispiel Anwendung zusammenstellt, hat keine besonderen Aufbau. Daher gehe ich an dieser Stelle nicht weiter ein.
Die Beispiel Anwendung
Mit der Anwendung können nach dem Verbindungsaufbau über USB, die Ein- und Ausgänge des Boards gesteuert oder gelesen werden.
Die Verbindung erfolgt über den Button 'Connect'. Hier ist jedoch zu beachten, wie die Jumper auf dem Board gesetzt sind. Etwas weiter links auf dem Board, ist eine Tabelle zu sehen, die Beschreibt, welche Adresse das Board bekommt, wenn die Jumper gesetzt entsprechend gesetzt wurden. Die vier Adressen ermöglichen auch wirklich vier von solchen Boards an einem PC betreiben zu können.
Intervall Abfrage ist nach dem Start der Anwendung auf 10ms eingestellt. Stellt die häufige Abfrage ein Problem dar, ist zu empfehlen, die Intervall Abfrage auf 100ms oder 1000ms, um zustellen, bevor die Verbindung hergestellt wird.
Hergestellte Verbindung
Nach dem Clicken des Buttons 'Connect' vergehen ein bis zwei Sekunden, bis die Anwendung Ergebnisse Zeigt, wie das Ausmessen der Analog-Eingänge.
Auf dem Board, sind an den Analog-Eingängen zwei Potentiometer, an dem man das Eingangspegel verstellen kann. Wird mit einem Kreuzschlitzschraubendreher daran gedreht, sollte sich in der Anwendung der Balken unter Analog Inputs verändern.
Mit dem Drücken der Buttons, sollten sich auf der GUI für 'Digital Inputs', die Schwarzen Rechtecke in grüne einfärben.
Die Digitalausgänge werden einfach per Checkbox ein- oder ausgeschaltet.
Bei den analogen Ausgängen, werden die Slider verstellt. Je weiter nach rechts gestellt, umso heller wird die LED am Analogen Ausgang.
Bekommt man das Board noch
Tatsächlich ja. Also wenn man das Board nicht gerade über eine Ebay Auktion bekommt, sollte der Artikel auch über Conrad Elektronik oder Reichelt zu finden sein. Viel hat sich nicht geändert. Vielleicht die Farbe, aber die Funktionalität dürfte noch die selbe sein. Falls nicht die Ansteuerung mit der LED und dessen Methodennamen sich nicht geändert haben. Das Board, dass ich verwendet habe, hat die Bezeichnung 'P8055-1'. Weicht diese ab, dann sollte man gegebenenfalls die DLL austauschen und für dies Beispiel-Anwendung durch die mitgelieferte DLL von der CD in die Visual Studio Projekt ersetzen.
Programmcode wie immer
Natürlich stelle ich den Programmcode wieder auf meinem Github Repository bereit.
Als ich mit meiner Freundin zum Geocaching unterwegs war, fiel mir ein das ich ein Programm schreiben wollte, das die Funktion eines Gästebuch ausführt und das auf einem Mikrocontroller. Wer Geocaching kennt, der weis dass man nach einem gefundenen Geocach sich auf einem beiliegenden Zettel eintragen kann. Und genau das will ich als digitale Form abbilden.
Was brauche ich:
Wemos
SD Shield
SD Karte
Anforderung
Für ein Gästebuch, soll ein (Nick)Name und Datum hinterlegt werden. Datum wird Automatisch mit dem Eintrag gesetzt, so das nur der Nickname eingetragen wird. Hier sollte der Wemos als eigenständiger Access-Point funktionieren, so das man sich mit dem Smartphone verbinden kann. Auf der Webseite sollten zwei Textboxen für die Eingabe des Namens und Datum vorhanden sein. Das Speichern erfolgt dann über ein Button. Neben der Eintrage Möglichkeit, soll auch eine Tabelle mit bereits bestehenden Einträgen zusehen sein. Und natürlich sollte der Zugang per Smartphone funktionieren.
Webseite schreiben
Leider kann die Webseite nicht mit Bootstrap aufgebaut werden. Na ja, eigentlich schon, aber das wäre ein eigenes Thema. Diesmal wird die Seite wieder Klassisch und einfach aufgebaut nach Standard HTML Bordmitteln.
Die Webseite wird mit in dem Programmcode eingebettet, so das diese nicht von der SD Karte erst gelesen werden muss. Zudem sollte bei defekter SD Karte trotzdem etwas angezeigt werden, wie eine Fehlermeldung.
Da die Darstellung des Code mit HTML auf dem Blog sich schwierig gestaltet, kann der Programmcode für den Seitenaufbau auf dem Github Repository betrachtet werden [Link zu WebsiteContent].
Die Seite habe ich zuvor ganz normal in HTML geschrieben und das Ergebnis im Browser immer wieder betrachtet. Erst danach kam die Seite in den Programmcode, das jedoch optisch grausig aussieht. Daher habe ich den Teil in einen eigenen Tab bzw. Code-Datei verfrachtet. Am Ende kam fast das selbe Ergebnis wie in den Folgendem Bild zu sehen ist.
Das Aktuelle Datum wird durch ComboBoxen ermöglicht, in dem man das aktuelle Datum und Uhrzeit einrichten kann. Das testen auf dem Windows Phone erfolgt ohne Probleme. Auf dem Iphone jedoch nicht.
SD Karte prüfen
Mit dem Start soll zunähst geprüft werden, ob die SD Karte verfügbar ist. Zuerst wird jedoch geprüft, ob bereits die Datei mit den Gästeeinträgen vorhanden ist. Wenn nicht, dann wird diese angelegt.
void InitSdCard() {
SD.begin(mChipSelect);
if(!SD.exists(mGuestList)){
Serial.println("Create file...");
File dataFile = SD.open(mGuestList, FILE_WRITE);
dataFile.close();
Serial.println("...finish");
}
else {
Serial.println("the guest list exist");
}
if(!mCard.init(SPI_HALF_SPEED, mChipSelect)) { // initialize sd card and check insert sd card
Serial.println("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
Serial.println("\t\tERROR: can not init sd card");
Serial.println("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
return;
}
if(!mVolume.init(mCard)) { // initialize sd card
Serial.println("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
Serial.println("\t\tERROR: Could not find FAT16/FAT32 partition.\nMake sure you've formatted the card");
Serial.println("XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
return;
}
Serial.println("SD Card: Exist and partion was found");
}
Wemos zum Access Point machen
Auf der Webseite von Wemos ist es eigentlich schon beschrieben, aber zur Vollständigkeit, nehme ich das hier denoch auf. Um den Wemos zum Access Point zu machen, bedarf dies nur weniger Code zeilen. In den Globalen Variablen muss dann nur die Instanz vom WiFiServer gehalten werden. Für den Zugang wird kein Passwort festgelegt.
void StartWebserver() {
Serial.println("Start access point");
WiFi.mode(WIFI_AP);
byte setMode = 0; // 0 = disable, 1 = enable router information
wifi_softap_set_dhcps_offer_option(OFFER_ROUTER, &setMode);
WiFi.softAP("VisitorsBook", "");
mServer.begin(); // Start the HTTP Server
Serial.print("Ip Address: ");
Serial.println(WiFi.softAPIP());// Send the IP address of the ESP8266 to the computer
}
Aufruf der Seite
Mit dem Aufruf der Seite wird zunächst geprüft, ob der Benutzer einen Request sendet. Wenn nicht, wird die Methode zum Speichern umgangen. Den Request nach dem Inhalt zuprüfen, sieht hier etwas kryptisch aus, das jedoch nur schlimm aussieht. Beim auslesen des Datums wird ein Doppelpunkt mit '%3A' ausgefüllt, der sich jedoch einfach mit einem Replace wieder in einen Doppelpunkt gesetzt werden kann. Nach dem aufbereiten des Namens und Datum kann das Ergebnis gespeichert werden.
Das Speichern erfolgt Zeilenweise. Hier wird der Eintrag mit einem Zeilenumbruch in die Datei hinzugefügt. Zuerst hatte ich nur Name und Datum, aber das Parsen wäre relativ aufwendiger geworden. Daher wird die Zeile bereits mit dem Ziel HTML Format eingetragen. (Wegen HTML Inhalt ist der folgende Code nur ein Bild)
Daten abrufen
Ist etwas lang die Methode. Aber auch nur, weil wieder etwas HTML mit eingeflossen ist, um die Anzahl der gespeicherten Einträge wieder zugeben. Anschließend werden die Gast Einträge aus der Datei Zeilenweise mit dem bereits HTML Anteil abgerufen und hinzugefügt.
Offene Punkte
Das Hübsch machen der Webseite habe ich weggelassen, sowie das Fehlerbehandlung. Denn ein Eintrag kann ohne Namen und Datum erfolgen und führt dazu, dass der Eintrag in der Liste wirklich leer sein kann. Zudem war auch ein RTC Timer angedacht, der jedoch für den ersten Schritt nicht benötigt wird. Im Main Branch habe ich diesen Teil stehen gelassen.
Und dann noch der Fehler, dass das Datum nicht geht, wenn versucht wird vom Iphone ein Request zu senden.
Datenschutz
Die Anmeldung erfolgt anonym, weil hier keine Personenbezogenen Daten erhoben werden. Um genau zu sein, dürfen hier keine Personendaten erhoben werden, wie Email (Macht in diesen Kontext auch kein Sinn), Namen und Geburtsdatum. Für den Eintrag reicht dann der Hinweis:
Mit meinem Eintrag ins Gästebuch erkläre ich mich einverstanden, dass dieser Beitrag mit Datum und Uhrzeit auf der Gästebuch-Seite hier Lokal öffentlich lesbar wird.
Manchmal sind Lösungen so einfach, dass man sie in der Komplexität nicht mehr sieht. So hatte ich tatsächlich eine Menge XAML Code schreiben müssen, die sich praktisch immer wiederholten. Die Premisse war, dass eigentlich nur ein Wert anders war im Template. Die beschriebenen Trigger und Style Aufbauten im Template sind in den meisten fällen identisch für ein Steuerelement. Eine andere Lösung wäre den Aufbau per Behind Code zu schreiben, aber das war tatsächlich die aufwendigere Lösung. Aufwendig, weil mehr code entstehen würde als nötigt.
Umgebung
WPF, UWP oder XAML Anwendung
Verschieden und doch gleich
Beide Buttons, sollen die gleichen überschriebenen Animationen erhalten. Der Butten bekommt ein Path Objekt, um die Darstellung für jeden Button zu individualisieren. Dabei soll kein Redundanter Code entstehen.
Los geht’s
Der Ordnung halber, wird der Style in eine eigene Resourcendatei geschrieben. Falls Ihr ein Xaml Path Objekt braucht, gibt es auf dem folgenden Link eine Seite mit vielen verschienden Icons.
Ausgangslage In diesem Beispiel verwende ich das File-Icon und ein Folder-Icon. Im folgenden XAML wird zunächst gezeigt, wie zwei Styles die fast identisch sind und sich nur in dem eingesetzten Path Control unterscheiden. Der XAML Auszug kommt aus 'ButtonWithRepeadTemplate.xaml' (Leider lies sich das nicht vernüfitg hier im Blog abbilden, mit ausnahme eines Bildes). Die orange markierten Zeilen, sind die Stellen die sich unterscheiden und grün sind die Teile, dessen Inhalt gleich sind. (Ellipse habe ich nicht mit einbezogen)
Die Lösung ist nahezu offensichtlich, dass dieser Aufbau selbst als Basis Style festegelgt werden kann. Da es sich um einen Wert handelt, kann über die nicht verwendete DependencyProperty 'Content' später die eigentliche Zieländerungen eingesetzt werden. Das sieht dann im folgendem XAML Code aus.
BLAU: Da kann ein Path Element gesetzt werden mit einer Default Zeichnung.
GRÜN: Der Teil, der später nicht ein zweites mal geschrieben werden muss. Wobei hier angemerkt werden muss, dass auch der Teil in der 'Viewbox' nur in der Basis besteht.
Kommen wir zu den Ziel Button Styles, die als Basis Style (BaseOn) von 'ButtonTemplateBase' verwenden. Durch zuweisung eines Path Element in Content, wird das Aussehen in form eines Icon festgelegt. Also der Teil, der wirklich sich unterscheidet.
Ausprobieren
Mit diesen neunen Button Styles geht das Zuweisen auf der Windows.xaml (oder andere View) weiter, um dort die Resource einzubetten und dessen Styles auf die Ziel Buttons festzulegen.
Fertig Und so sollte das Ergebnis aussen. Beide Buttons sollte die gleichen Trigger Effekte haben.
Zum Schluss
Wie ich bereits am Anfang geschrieben habe, ist die Lösung simpel und sehr überschaubar. Selbst wenn deutlich mehr Wpf Steuerelemente mit dem gleichen Style Grundaufbau kommen, bleibt die Übersicht.
Aber wie sieht es aus, wenn sich zwei Inhalte im Template unterscheiden? Kurz gesagt, das geht. Aber dazu gehe ich in einem späteren Blogpost darauf ein.
Um seine Daten auf eine SD Karte zu speichern, muss am Anfang klar sein, in welches Format gespeichert werden soll. Das ist dann besonders entscheidend, wenn die Daten wieder weiter verarbeitet werden.
Wenn für den Moment nur ein Wert gespeichert wird, dann reicht Zeilenweise. Bei mehreren Werten müssen die durch ein Zeichen getrennt werden. Das kann dann über Leerzeichen, Semikolon oder Komma sein, bzw. CSV entsprechend. CSV kann prima in Excel betrachtet und weiter verarbeitet werden. Will man die Daten aber weiter verarbeiten, müssen am Ende viele Zeichenketten zu Werten umgewandelt werden. Grundsätzlich ist das nicht verkehrt, aber man muss sich selbst Standards festlegen, um Aufwände gering zu halten.
Was benötigt wird:
Arduino oder Wemos
SD Shield
SD Karte
Vorhaben
Für das Ziel Projekt werden einige Werte aus den Messungen gespeichert. Daher wird ein Daten Objekt benötigt, dass alle Werte enthält. Um dieses nicht im Arbeitsspeicher zu halten, muss der Inhalt persistiert werden und später auf Abruf wieder hergestellt werden.
Datenformat zum Speichern
Die Entscheidung war für mich schon ein eigenes Thema Wert. Neben CSV blicke ich seit langem wieder auf JSON und XML. Nachdem ich ein wenig gesucht habe, kam ich zu dem Entschluss JSON zu verwenden. Für das JSON Format existiert bereits eine fertige Bibliothek.
Nach einigen Tutorials und veralteten Beispielen, hatte ich die nötigen Informationen zusammen. Zum Schreiben und lesen der Daten können zwei verschiedene Klassen zum Verarbeiten von JSON verwendet werden.
StaticJsonDocument
DynamicJsonDocument
Für meinen Einsatz reicht der StaticJsonDocument aus. Dieser legt die Daten im Stack ab und verbraucht durch die Eingrenzung auch weniger Arbeitsspeicher. Das ist bedingt durch den Mikrocontroller, vorzugsweise zu verwenden.
Mit DynamicJsonDocument werden die Daten im Heap abgesetzt. Für mein Beispiel konnte ich evaluieren, das nur zwölf Datensätze zuverlässig verarbeitet werden, ohne dass das Programm anhält bzw. abstürzt.
Datenobjekt
Der Zeitpunkt der Aufnahme und zu speichernden Werte werden für den späteren verlauf verwendet. Sekunden habe ich hier nicht einbezogen, da solche Zeitlich genauen Messungen für die Darstellung keinen Nutzen hat. Die Stunden und Minuten werden wiederum verwendet, um später Durchschnittswerte zu berechnen. Der Tag, Monat und Jahr werden ebenfalls für die Durchschnittswerte verwendet.
Bevor die Daten in das JsonObject geschrieben werden können, muss die Größe definiert werden. Im folgenden Programmcode in "JSON_ARRAY_SIZE" der Wert 1 eingetragen, weil hier nur einmal gespeichert wird. Im Github Repository zeige ich dieses mit mehreren Datensätzen.
void SaveData() {
const size_t capacity = JSON_ARRAY_SIZE(1) * JSON_OBJECT_SIZE(8);
StaticJsonDocument jsonBuffer; // set the content size
JsonArray data = jsonBuffer.to(); // setup array to write
JsonObject measure = data.createNestedObject(); // created to add a new object
measure["Day"] = 12;
measure["Month"] = 12;
measure["Year"] = 2019;
measure["Hour"] = 12;
measure["Minute"] = 12;
measure["Temperature"] = 21.4f;
measure["Humidity"] = 50.5f;
measure["Pressure"] = 12345.6f;
File file = SD.open(mFilename, FILE_WRITE);
serializeJson(jsonBuffer, file); // write json document to file
file.close();
}
Öffnen (Deserialisierung)
Genau wie beim Speichern, wird ebenfalls die Kapazität angegeben. Dann kann die eingelesene Datei aus dem JSON Inhalt eingelesen und als JsonObject abgerufen werden.
Das JSON Format kann ich jeden nahe legen, dieses zu verwenden. Sobald mehrere Daten erfasst und gespeichert werden, ist der Aufwand beim Einlesen nahezu gleich. Das mag anfangs etwas ungewohnt sein. Spätestens wenn ihr Daten aus einer CSV einlesen wollt, das innerhalb des Mikrocontroller Bereiches geschieht, spart ihr eine Menge Zeit für das Entwickeln einer Methode, dass die Daten einließt.
Normalerweise sind 3D Objekte die Importiert wurden oder programmatisch erstellt wurden sehr statisch. Man kann zwar das 3d Objekt per Transformation bewegen, aber wenn man aus dem Objekt einzelnen Polygone bewegen will, dann sieht das ganze schon etwas schwierig aus.
Vor Jahren hatte ich mal ein PDF Dokument erhalten, das die Animationen eines 3D Models beschrieb. Leider bin ich nie dazugekommen, dies auszuprobieren und mit der Zeit ist auch das Dokument auch verloren gegangen.
Die folgende Beschreibung ist sicherlich für Performante Ausführungen nicht geeignet und ist daher nur für weniger Aufwendige Anwendungen gedacht. Wenn man nicht auf alternativen und kompromisse eingeht.
Grundaufbau
Für dieses Beispiel verwende ich einen Auszug von meinem bereits erstellten Beispiel Projekt 'ExampleMoveOnMap3d' und habe dazu einen neuen Branch mit dem Namen Waterwaves erstellt. In diesen Beispiel werden diesmal keine 3d Objekte importiert, sondern hier wird komplett eine Fläche erzeugt, auf die eine Textur zugewiesen wird.
Die neue Klasse AnimatedWaterwaves wird in den Ordner Map eingesetzt.
Parameter Angaben
Die ersten Members legen die Einstellungen der Fläche fest. Das gewählte Raster 20 mal 20 sollte auf den meisten derzeitigen Rechner ohne weiteres flüssig darstellbar sein. Die sich daraus ergebenen 1600 Polygone sind nicht viel, aber werden vor jedem Rendern neu erzeugt und das kostet Rechenzeit. Aber dazu später mehr.
Die Member Variablen _waveStartX und _waveStartY, legen den aktuellen Startwert beim erzeugen der Wellen fest. Die restlichen Member werden allgemein benötigt, um die Fläche zu halten.
Die üblichen Methoden wie Initialize, Draw und Update sind hier weniger Interessant. Dafür ist die Erzeugung der Wellen ansprechender. Die Methode 'RegenerateVertexBuffer' ist relative lang, was dem geschuldet ist, das ich den Hauptteil in der doppelten Schleife nicht ausgelagert habe.
Nun muß noch in ComponentMap mit der neuen Klasse umgestellt werden. Dort wo die 3d Objekte geladen wurden, wird nun die AnimatedWaterwaves instanziiert und initialisiert.
public class ComponentMap : GameComponent
{
private const float _speed = 0.3f;
private readonly ComponentInputs _componentInputs;
private AnimatedWaterwaves _animatedWaterwaves;
public ComponentMap(Game game, ComponentInputs componentInputs) : base(game)
{
this._componentInputs = componentInputs;
}
public override void Initialize()
{
var texture = this.Game.Content.Load("rpgTile029");
this._animatedWaterwaves = new AnimatedWaterwaves(texture);
this._animatedWaterwaves.Initialize(this.Game.GraphicsDevice);
}
public override void Update(GameTime gameTime)
{
RasterizerState raster = new RasterizerState();
raster.CullMode = CullMode.CullCounterClockwiseFace;
raster.FillMode = FillMode.WireFrame;
this.Game.GraphicsDevice.RasterizerState = raster;
this._animatedWaterwaves.Update(this.Game.GraphicsDevice);
}
public void DrawContent(Matrix view, Matrix projection)
{
this.Game.GraphicsDevice.DepthStencilState = DepthStencilState.Default;
this._animatedWaterwaves.Draw(this.Game.GraphicsDevice, view, projection);
}
}
Ausprobieren
Auf dem PC sollte nun nach dem Start die Animation flüssig abgespielt werden. Vergrößert man die Fläche und gibt mehr Polygone hinzu, dann wird irgendwann der Rechner in die Knie gehen. Wie immer testete ich dies auch auf dem Raspberry Pi, das auf dem folgenden Video zu sehen ist.
Gleich verbessern
Ehrlich gesagt, hat mich das dann doch gestört. Also schrieb ich am Beispiel weiter und auch weil ich bereits eine Lösung im Kopf hatte, die ich ausprobieren wollte. Stellt man die Variablen _tilesX und _tilesY auf 100, dann kommen wir hier auf 400.000 Polygone, aber die Animation ruckelt.
Das Problem ist das ständig neu erzeugen der Wellen.
Die Lösung hierfür wäre die Wellenanimation vorher zu generieren und dann die fertige Wellen abzuspielen.
Gitter Erstellen auslagern
Als nächstes wird ein Teil aus der Klasse AnimatedWaterwaves in eine neue Klasse ausgelagert und zwar der Bestandteil für die Polygonen. In diesen Zug wird dann auch erstmal aufgeräumt. Alles was doppelt erscheint wird zu einer Methode zusammengezogen. Damit wird auch die Hauptmethode für das erstellen der Fläche etwas kleiner. Einstellungen werden als const oder einfach als readonly Member angelegt. Der Konstruktor wird nicht gebraucht, auch wenn man den Teil in Initialize, dort hätte einsetzen können.
public class BufferedWave
{
private float _waveStepX = .05f;
private float _waveStepY = .2f;
private readonly int _tilesX = 20;
private readonly int _tilesY = 20;
private readonly float _squareLength = 1f;
private float _textureSize;
public VertexBuffer VertexBuffer;
public IndexBuffer IndexBuffer;
public int VertexCount;
private int _indexCount;
public List VertexPositions;
public const float WaveHeight = 1.4f;
public const int WaveLengthX = 10;
public const int WaveLengthY = 10;
public void Initialize(int step, GraphicsDevice graphicsDevice)
{
this._textureSize = 5f / this._tilesY;
this.VertexPositions = this.GenerateVertexBuffer(step, graphicsDevice);
}
private List GenerateVertexBuffer(int step, GraphicsDevice graphicsDevice)
{
this.ClearBuffers();
var waveStartX = this._waveStepX * step;
var waveStartY = this._waveStepY * step;
List vertices = new List();
List index = new List();
for (int iY = 0; iY < this._tilesY; iY++)
{
for (int iX = 0; iX < this._tilesX; iX++)
{
this.SetIndex(ref index, vertices.Count);
float waveX = iX + waveStartX;
float waveY = iY + waveStartY;
float height1 = this.GetHeight(waveX, waveY, 0, 1);
float height2 = this.GetHeight(waveX, waveY, 1, 1);
float height3 = this.GetHeight(waveX, waveY);
float height4 = this.GetHeight(waveX, waveY, 1, 0);
var verticePos1 = new Vector3((iX * this._squareLength), (iY * this._squareLength) + this._squareLength, height1);
var verticePos2 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength) + this._squareLength, height2);
var verticePos3 = new Vector3((iX * this._squareLength), (iY * this._squareLength), height3);
var verticePos4 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength), height4);
float aaX = this.GetTextureCoordinate(iX);
float aaY = this.GetTextureCoordinate(iY);
float aaX1 = this.GetTextureCoordinate(iX) + this._textureSize;
float aaY1 = this.GetTextureCoordinate(iY) + this._textureSize;
vertices.Add(new VertexPositionNormalTexture(verticePos1, Vector3.Up, new Vector2(aaX, aaY1)));
vertices.Add(new VertexPositionNormalTexture(verticePos2, Vector3.Up, new Vector2(aaX1, aaY1)));
vertices.Add(new VertexPositionNormalTexture(verticePos3, Vector3.Up, new Vector2(aaX, aaY)));
vertices.Add(new VertexPositionNormalTexture(verticePos4, Vector3.Up, new Vector2(aaX1, aaY)));
}
}
this.VertexCount = vertices.Count;
this._indexCount = index.Count;
this.InitVertexBuffer(graphicsDevice, vertices);
this.IndexBuffer.SetData(index.ToArray());
return vertices;
}
private float GetHeight(double waveX, double waveY, int x = 0, int y = 0)
{
float heightY = (float)Math.Sin(((waveY + y) * Math.PI) / WaveLengthY);
float heigthX1 = (float)Math.Sin((float)((waveX + x) * Math.PI) / WaveLengthX);
return (heigthX1 + heightY) * WaveHeight;
}
private float GetTextureCoordinate(int index)
{
var textureCoordinate = this._textureSize * index;
if (textureCoordinate >= 1f)
{
textureCoordinate %= 1f;
}
return textureCoordinate;
}
private void InitVertexBuffer(GraphicsDevice graphicsDevice, List vertices)
{
this.VertexBuffer = new VertexBuffer(graphicsDevice, VertexPositionNormalTexture.VertexDeclaration, this.VertexCount, BufferUsage.WriteOnly);
this.VertexBuffer.SetData(vertices.ToArray());
this.IndexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.ThirtyTwoBits, this._indexCount, BufferUsage.WriteOnly);
}
private void ClearBuffers()
{
if (this.VertexBuffer != null)
{
this.VertexBuffer.Dispose();
this.VertexBuffer = null;
}
if (this.IndexBuffer != null)
{
this.IndexBuffer.Dispose();
this.IndexBuffer = null;
}
}
private void SetIndex(ref List index, int localOffset)
{
index.Add(localOffset + 0);
index.Add(localOffset + 1);
index.Add(localOffset + 3);
index.Add(localOffset + 0);
index.Add(localOffset + 3);
index.Add(localOffset + 2);
}
}
In der AnimatedWaterwaves bleiben in den Member die Textur und der BasicEffect. Neu ist das Dictionary das später die alle erstellten schritte der Welle beinhaltet. Der index wird in der Update Methode hochgezählt oder zurück gesetzt, wenn das ende des Index Bereiches erreicht ist.
Mit der Methode Initialize werden nun alle schritte generiert. Der BufferIndexSize von 1600 ist mehr zufällig geschätzt, der genau paßt um die Animation in der Schleife abzubilden. Am Ende wird in der Methode Draw noch die bereits angelegten Vertex Werte zugewiesen, nach dem diese per Index Wert aus dem Dictionary abgerufen werden. In den Parametern kommt noch eine offsetPosition und ein offsetIndex rein und ermöglicht den Inhalt zu rendern, der gerade gebraucht wird.
public class AnimatedWaterwavesBuffered
{
private BasicEffect _effect;
private readonly Texture2D _texture;
private Dictionary _dictionaryVertexPositions = new Dictionary();
public int Index { get; private set; } = 0;
public AnimatedWaterwavesBuffered(Texture2D texture)
{
this._texture = texture;
}
public void Initialize(GraphicsDevice graphicsDevice)
{
this._effect = new BasicEffect(graphicsDevice);
this._effect.World = Matrix.Identity;
this._effect.TextureEnabled = true;
this._effect.Texture = this._texture;
this._effect.EnableDefaultLighting();
// is estimated
int bufferIndexSize = 1600;
for (int i = 0; i < bufferIndexSize; i++)
{
BufferedWave bufferedWave = new BufferedWave();
bufferedWave.Initialize(i, graphicsDevice);
this._dictionaryVertexPositions.Add(i, bufferedWave);
}
}
public void Update()
{
if (this.Index >= this._dictionaryVertexPositions.Count - 1)
{
this.Index = 0;
}
else
{
this.Index++;
}
}
internal void Draw(GraphicsDevice graphicsDevice, Matrix view, Matrix projection, Vector3 offsetPosition, int offsetIndex)
{
this._effect.View = view;
this._effect.Projection = projection;
var index = this.GetIndex(this.Index + offsetIndex);
var item = this._dictionaryVertexPositions[index];
graphicsDevice.SetVertexBuffer(item.VertexBuffer);
graphicsDevice.Indices = item.IndexBuffer;
this._effect.World = Matrix.CreateTranslation(offsetPosition);
foreach (EffectPass pass in this._effect.CurrentTechnique.Passes)
{
pass.Apply();
graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, item.VertexCount);
}
}
private int GetIndex(int index)
{
if (index >= this._dictionaryVertexPositions.Count)
{
index -= this._dictionaryVertexPositions.Count;
return this.GetIndex(index);
}
return index;
}
}
Umstellung und Fläche vergrößern
In Update entfällt nun die Übergabe des GraphicsDevice und in DrawContent, wird mit einer doppelten Schleife die Fläche mehrmals an verschiedenen Positionen gerendert.
public class ComponentMap : GameComponent
{
private const float _speed = 0.3f;
private readonly ComponentInputs _componentInputs;
public AnimatedWaterwavesBuffered AnimatedWaterwavesBuffered;
public ComponentMap(Game game, ComponentInputs componentInputs) : base(game)
{
this._componentInputs = componentInputs;
}
public override void Initialize()
{
var texture = this.Game.Content.Load("rpgTile029");
// the buffered waves
this.AnimatedWaterwavesBuffered = new AnimatedWaterwavesBuffered(texture);
this.AnimatedWaterwavesBuffered.Initialize(this.Game.GraphicsDevice);
}
public override void Update(GameTime gameTime)
{
RasterizerState raster = new RasterizerState();
raster.CullMode = CullMode.CullCounterClockwiseFace;
raster.FillMode = FillMode.WireFrame;
this.Game.GraphicsDevice.RasterizerState = raster;
// the buffered waves
this.AnimatedWaterwavesBuffered.Update();
}
public void DrawContent(Matrix view, Matrix projection)
{
this.Game.GraphicsDevice.DepthStencilState = DepthStencilState.Default;
// the buffered waves
for (int iY = 0; iY < 10; iY++)
{
for (int iX = 0; iX < 10; iX++)
{
this.AnimatedWaterwavesBuffered.Draw(this.Game.GraphicsDevice, view, projection, new Vector3(200 - (iX * 20), 200 - (iY * 20), 0), 400 * iY);
}
}
}
}
Die CameraView sollte eine neue Start Position erhalten, damit die neu erzeugte Fläche in der Mitte des RenderScreens erscheint. Zuvor war es, das die Fläche bewegt wurde. In diesen Branch kann man allerdings auch die Kmaera mit den Tasten WASD bewegen.
Leistungsgewinn
Mit 400.000 Polygonen fing die nicht gepufferte Version schnell an zu Ruckeln. Diese Anzahl sollte für ein durchschnittlichen PC oder Notebook möglich sein. Leider existieren auf dem Markt Spiele, die bei sehr geringen Grafikdetails auf kleinen System nur rückelig angezeigt werden.
Hier muß man sich bewußt sein, daß der Teil nicht von der Grafikkarte berechnet wird. Die Umstellung mit gepufferten VertexBuffern führt zu einer geringeren CPU Last und wirkt sich dementsprechend mit einer flüssigen Animation aus.
Fazit
Eigentlich hatte ich nicht vor die gepufferte Version mit rein zu nehmen. Wie immer habe ich den Rapberry Pi verwendet, um zu sehen, wie sich diese Lösung auswirkt. Zwar ruckelt es dort, aber bei weiten nicht so schlimm wie vorher.
Im Grunde war dies auch eine Problem Darstellung und eine mögliche Lösung, ohne gleich HLSL Programmierung anzusetzen. Manipulationen von Drahtgitter Modellen lassen sich besser durch die Grafikarte berechnen und das ging schon bereits mit DirectX 8.1.
Spiele machen mehr Spaß, wenn man sie zu zweit spielen kann. Mit MonoGame lässt sich ein Splitscreen mit geringen Aufwand umsetzen. Hierfür verwende ich von mir bereits angelegten Beispiel Code "ExampleMoveOnMap3d" aus dem Blogpost "3d Karte Bewegen".
Eingabegeräte
Für die Steuerung werden als Beispiel Tastatur Eingaben und Xbox Controller aufgeteilt. Daher wird nun vom Typen InputData eine zweite Property angelegt und entsprechend benannt. Die Eingabe muss beim jeden Aufruf immer zurück gesetzt werden, deshalb kommt ein Reset, sowie eine Normalize Methode und halten damit unsere Übersicht in ComponentInput Klasse.
public class InputData
{
public Vector2 Move => new Vector2(this.MoveX, this.MoveY);
public float MoveX { get; set; }
public float MoveY { get; set; }
public void Reset()
{
this.MoveX = 0;
this.MoveY = 0;
}
public void Normalize()
{
this.MoveX = Math.Min(1, Math.Max(-1, this.MoveX));
this.MoveY = Math.Min(1, Math.Max(-1, this.MoveY));
}
}
Bei mehreren Gamepads, könnte man die Eingabeerfassung auch in der Schleife abfragen, aber bleiben wir zunächst beim Hartverdrahten.
Die Karte muss nun zweimal geladen werden, damit beide Spieler eine Ansicht über ihre Karten Position erhalten. Eine "IF" Abfrage fragt die aktuelle Eingabe ab.
Die
im Titelbild zu sehenden Fahrzeuge, habe ich hier nicht mit reingenommen, da
der Inhalt ähnlich des Types PlateGrassTile ist. Allerdings ist es wiederum auf
meinem Githab Repository zu finden.
public class ComponentMap : GameComponent
{
private const float _speed = 0.3f;
private readonly ComponentInputs _componentInputs;
private readonly int _player;
private PlateGrassTile[] _groundTiles = new PlateGrassTile[9];
public ComponentMap(Game game, ComponentInputs componentInputs, int player) : base(game)
{
this._componentInputs = componentInputs;
this._player = player;
}
public override void Initialize()
{
var grass = this.Game.Content.Load("Plate_Grass_01");
int index = 0;
for (int iY = 0; iY < 3; iY++)
{
for (int iX = 0; iX < 3; iX++)
{
this._groundTiles[index] = new PlateGrassTile(this.GetPosition(iX, iY),
0.5f,
grass);
index++;
}
}
}
public override void Update(GameTime gameTime)
{
Vector3 positon = new Vector3();
if (this._player == 1)
{
positon = new Vector3(this._componentInputs.Inputs1.MoveX, this._componentInputs.Inputs1.MoveY, 0) * _speed;
}
else
{
positon = new Vector3(this._componentInputs.Inputs2.MoveX, this._componentInputs.Inputs2.MoveY, 0) * _speed;
}
foreach (var item in this._groundTiles)
{
item.Position += positon;
}
}
public void DrawContent(Matrix view, Matrix projection)
{
foreach (var item in this._groundTiles)
{
item.Draw(view, projection);
}
}
private Vector3 GetPosition(int x, int y)
{
float mapLength = 3f;
float distance = .02f;
float centerMap = (mapLength + distance)
* (float)Math.Sqrt(this._groundTiles.Length) / 2; ;
return new Vector3((y * (mapLength + distance)) - centerMap,
(x * (mapLength + distance)) - centerMap,
0);
}
}
Von der 'ComponentMap' wird nun eine zweite Instance angelegt in der Game1.cs. Die 'UpdateOrder' muss dann entsprechend angepasst werden für den ComponentRender und im Konstruktor Parameter wird die zweite Instance eingereicht.
public class Game1 : Game
{
private readonly GraphicsDeviceManager _graphics;
private readonly ComponentInputs _componentInputs;
private readonly ComponentMap _componentMapPlayer1;
private readonly ComponentMap _componentMapPlayer2;
private readonly ComponentRender _componentRender;
public Game1()
{
this._graphics = new GraphicsDeviceManager(this);
this.Content.RootDirectory = "Content";
this.Window.Title = "Move on map 3d";
this.IsMouseVisible = true;
this._componentInputs = new ComponentInputs(this);
this._componentInputs.UpdateOrder = 1;
this.Components.Add(this._componentInputs);
this._componentMapPlayer1 = new ComponentMap(this, this._componentInputs, 1);
this._componentMapPlayer1.UpdateOrder = 2;
this.Components.Add(this._componentMapPlayer1);
this._componentMapPlayer2 = new ComponentMap(this, this._componentInputs, 2);
this._componentMapPlayer2.UpdateOrder = 3;
this.Components.Add(this._componentMapPlayer2);
this._componentRender = new ComponentRender(this, this._componentMapPlayer1, this._componentMapPlayer2);
this._componentRender.UpdateOrder = 4;
this.Components.Add(this._componentRender);
}
}
Bildausgabe aufteilen
Nun kann auch schon der Render Bereich angepasst werden. Zunächst wird eine Anpassung an der 'CameraView' Klasse vorgenommen. In der Methode 'Initialize()' muss die Projektion auf die Hälfte reduziert werden. Für die Rechte Seite muss der zu anzufangende Viewport auf die Mitte des Bildschirms verschoben werden. In 'Draw()' muss dann der Eingerichtete ViewPort für den Renderbereich zugewiesen werden.
public class CameraView
{
private readonly Game _game;
private readonly ComponentMap _componentContent;
public Vector3 Position { get; set; }
public Matrix View { get; private set; }
public Matrix Projection { get; private set; }
private bool _isRightView = false;
private Viewport _viewPort;
public CameraView(Game game, ComponentMap componentMap, bool isRightView)
{
this._game = game;
this._componentContent = componentMap;
this._isRightView = isRightView;
}
public void Initialize()
{
this._viewPort = this._game.GraphicsDevice.Viewport;
this._viewPort.Width = this._viewPort.Width / 2;
if (this._isRightView)
{
this._viewPort.X = this._viewPort.Width;
}
var aspectRatio = this.GetAspectRatio();
var position = new Vector3(-1f, 5f, 5f);
var target = new Vector3(0, 0, 0);
var farPlaneDistance = 10000;
this.View = Matrix.CreateLookAt(position, target, Vector3.Backward);
this.Projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4,
aspectRatio,
1,
farPlaneDistance);
}
public void Draw()
{
this._game.GraphicsDevice.Viewport = this._viewPort;
this._componentContent.DrawContent(this.View, this.Projection);
}
private float GetAspectRatio()
{
var w = (float)ApplicationView.GetForCurrentView().VisibleBounds.Width / 2;
var h = (float)ApplicationView.GetForCurrentView().VisibleBounds.Height;
return w / h;
}
}
Aus einer 'CameraView' werden nun zwei. Beide haben nun den zusätzlichen Parameter mit der sich dann der Splitinhalt von Links oder Rechts einstellen lässt. Das Initialisieren der 'CamereView', muss dann über die zu überschreibende Methode 'Initialize()' abgearbeitet werden von der geerbten Klasse 'DrawableGameComponente'.
public class ComponentRender : DrawableGameComponent
{
private readonly CameraView _cameraViewLeft;
private readonly CameraView _cameraViewRight;
public ComponentRender(Game game, ComponentMap componentContent, ComponentMap componentContent2) : base(game)
{
this._cameraViewLeft = new CameraView(game, componentContent, false);
this._cameraViewRight = new CameraView(game, componentContent2, true);
}
public override void Initialize()
{
this._cameraViewLeft.Initialize();
this._cameraViewRight.Initialize();
}
public override void Draw(GameTime gameTime)
{
this.GraphicsDevice.Clear(Color.CornflowerBlue);
this._cameraViewLeft.Draw();
this._cameraViewRight.Draw();
}
}
Nachwort
Wie zu sehen ist, ist die Umsetzung für Split Screen nicht sonderlich kompliziert und ist auch noch in wenigen Schritten machbar. Mit ein wenig mehr Aufwand kann das OpenClosePrinzip angewendet werden, womit dann anschließend eine Umsetzung eines vier Spieler Modus erleichtert.