Einfache 3D-Grafik
von Daniel Noll
Übersicht
Im zweiten Tutorial aus der Reihe “Managed DirectX mit VB .NET” soll es darum gehen, wie sie einen einfachen dreidimensionalen Würfel auf den Bildschirm zaubern können. Das geht allerdings nicht ohne viel Theorie...
Unsere 3D-Welt
Der Titel "3D-Welt" ist zwar etwas hochgegriffen, da wir nur einen einfachen Würfel verwenden werden, aber letztendlich gilt das, was wir hier lernen auch für 3D-Landschaften und sonstiges. Bevor wir uns allerdings darum kümmern diesen Würfel auf den Bildschirm zu bringen, müssen wir wissen, wie der Würfel im Speicher abgelegt wird.
Abbildung 1: Verschiedene Formen aus Dreiecken
Wenn Sie sich schon ein wenig mit dem Thema 3D-Grafik (nicht unbedingt Direct3D) beschäftigt haben, so werden Sie wissen, dass sämtliche Objekte in einer dreidimensionalen Welt aus Polygonen, genauer gesagt Dreiecken, bestehen. Der Vorteil von Dreiecken gegenüber Rechtecken, usw. ist, dass es zum einen das einfachste Polygon ist und zum anderen nie nach innen oder außen gewölbt sein kann (konkav / konvex). So wie ein Rechteck z.B. aus zwei gleichen Dreiecken besteht, so kann man auch sämtliche anderen Figuren aus Dreiecken bauen:
Es gibt mehrere Möglichkeiten diese Dreiecke zu speichern.
TriangleList
Die TriangleList ist wohl die einfachste Art eine Form als Dreieck zu speichern. Es werden einfach die Dreiecke mit drei Punkten pro Dreieck gespeichert: 0, 1 & 2; 3, 4 & 5
Abbildung 2: Beispiel für eine Triangle-List
TriangleStrip
Der TriangleStrip hat gegenüber der TriangleList den Vorteil weniger Platz zu verbrauchen, da immer zwei Punkte der vorigen Dreiecks geteilt werden. Der Nachteil liegt daran, dass sie bei weitem nicht so flexibel sind und bei der Verwendung von Lighting zu Darstellungsfehlern führen können. 1, 2 & 3; 2, 3 & 4; 3, 4 & 5; 4, 5 & 6 ...
Abbildung 3: Beispiel für ein TriangleStrip
TriangleFan
TriangleFans sind vor allem für kreisformige Strukturen geeignet. Hier wird allerdings der erste Punkt (Vertex) mit allen Dreiecken geteilt. 0, 1 & 2; 0, 2 & 3; 0, 3 & 4
Abbildung 4: Beispiel für einen TriangleFan
LineList
Die LineList speichert eine Gruppe von Punkten und verbindet jeweils zwei mit einer 1 Pixel schmalen Linie: 0 & 1; 2 & 3; 4 & 5
Abbildung 5: Beispiel für eine Line-List
LineStrip
Der LineStrip verbindet aufeinanderfolgende Vertices miteinander: 0 & 1; 1 & 2; 2 & 3; 3 & 4 ...
Abbildung 6: Beispiel für einen Line-Strip
PointList
Die PointList hat ihren Einsatzbereich vor allem in Bezug mit Partikelsystemen verwendet. Hier werden die einzelnen Vertices als 1 Pixel gezeichnet
Abbildung 7: Beispiel für eine Point-List
Für den Würfel in den Beispielen zu diesem Tutorial habe ich eine TriangleList benutzt. Besser wäre natürlich ein TriangleStrip, dies wäre jedoch für den Anfang ein wenig kompliziert:
' Die obere Seite cube(0) = New CustomVertex.PositionColored(-1, 1, 1, Color.Blue.ToArgb) cube(1) = New CustomVertex.PositionColored(1, 1, 1, Color.Red.ToArgb) cube(2) = New CustomVertex.PositionColored(-1, 1, -1, Color.Blue.ToArgb) cube(3) = New CustomVertex.PositionColored(1, 1, -1, Color.Red.ToArgb) cube(4) = New CustomVertex.PositionColored(-1, 1, -1, Color.Blue.ToArgb) cube(5) = New CustomVertex.PositionColored(1, 1, 1, Color.Red.ToArgb) ' Die untere Seite cube(6) = New CustomVertex.PositionColored(-1, -1, -1, Color.Blue.ToArgb) cube(7) = New CustomVertex.PositionColored(1, -1, 1, Color.Red.ToArgb) cube(8) = New CustomVertex.PositionColored(-1, -1, 1, Color.Blue.ToArgb) cube(9) = New CustomVertex.PositionColored(1, -1, 1, Color.Red.ToArgb) cube(10) = New CustomVertex.PositionColored(-1, -1, -1, Color.Blue.ToArgb) cube(11) = New CustomVertex.PositionColored(1, -1, -1, Color.Red.ToArgb) ' Die linke Seite cube(12) = New CustomVertex.PositionColored(-1, 1, -1, Color.Blue.ToArgb) cube(13) = New CustomVertex.PositionColored(-1, -1, -1, Color.Blue.ToArgb) cube(14) = New CustomVertex.PositionColored(-1, 1, 1, Color.Blue.ToArgb) cube(15) = New CustomVertex.PositionColored(-1, 1, 1, Color.Blue.ToArgb) cube(16) = New CustomVertex.PositionColored(-1, -1, -1, Color.Blue.ToArgb) cube(17) = New CustomVertex.PositionColored(-1, -1, 1, Color.Blue.ToArgb) ' Die rechte Seite cube(18) = New CustomVertex.PositionColored(1, 1, -1, Color.Red.ToArgb) cube(19) = New CustomVertex.PositionColored(1, 1, 1, Color.Red.ToArgb) cube(20) = New CustomVertex.PositionColored(1, -1, -1, Color.Red.ToArgb) cube(21) = New CustomVertex.PositionColored(1, -1, 1, Color.Red.ToArgb) cube(22) = New CustomVertex.PositionColored(1, -1, -1, Color.Red.ToArgb) cube(23) = New CustomVertex.PositionColored(1, 1, 1, Color.Red.ToArgb) ' Die Rückseite cube(24) = New CustomVertex.PositionColored(-1, 1, -1, Color.Blue.ToArgb) cube(25) = New CustomVertex.PositionColored(1, 1, -1, Color.Red.ToArgb) cube(26) = New CustomVertex.PositionColored(-1, -1, -1, Color.Blue.ToArgb) cube(27) = New CustomVertex.PositionColored(1, -1, -1, Color.Red.ToArgb) cube(28) = New CustomVertex.PositionColored(-1, -1, -1, Color.Blue.ToArgb) cube(29) = New CustomVertex.PositionColored(1, 1, -1, Color.Red.ToArgb) ' Die Vorderseite cube(30) = New CustomVertex.PositionColored(1, 1, 1, Color.Red.ToArgb) cube(31) = New CustomVertex.PositionColored(-1, 1, 1, Color.Blue.ToArgb) cube(32) = New CustomVertex.PositionColored(-1, -1, 1, Color.Blue.ToArgb) cube(33) = New CustomVertex.PositionColored(-1, -1, 1, Color.Blue.ToArgb) cube(34) = New CustomVertex.PositionColored(1, -1, 1, Color.Red.ToArgb) cube(35) = New CustomVertex.PositionColored(1, 1, 1, Color.Red.ToArgb)
Listing 1: Der Code für den Würfel
Der Code sieht ersteinmal schrecklicher aus, als er wirklich ist. Die Vertices werden alle in ein Array vom Typ CustomVertex.PositionColored gespeichert. Diese Sorte Vertices haben, wie der Name schon sagt, eine Position und eine Farbe.
Allerdings ist diese Methode die Vertices zu erstellen sehr umständlich. Bei einfachen Körpern, wie Kugeln oder Würfeln, die einer einfachen Logik folgen, geht es gerade noch, für Körper und ähnlichem gibt es die Möglichkeit die Körper in einem Programm zu erstellen und dann mit Direct3D zu laden (doch dazu mehr in einem anderen Tutorial).
Culling
Unser Würfel hat 6 Seiten, allerdings können nur maximal drei dieser Seiten auf einem Bild zu sehen sein. Da diese Seiten aber dennoch gerendert werden müssten, auch wenn sie auf dem finalen Bild nicht mehr zu sehen sind, fügt Direct3D eine kleine Optimierung hinzu, die bewirkt, dass Polygone, die vom Betrachter wegzeigen ausgelassen werden. Die Entscheidung welche Dreiecke weggelassen werden können, wird über die Ordnung der Vertices eines Dreiecks getroffen.
Abbildung 8
Mit den Standard-Culling-Einstellungen von Direct3D würde nur das erste Dreieck gezeichnet werden. Der Grund ist, dass die Vertices des ersten Dreiecks im Uhrzeigersinn angeordnet sind, während die Ordnung im zweiten Dreieck gegen den Uhrzeigersinn ist. Würden wir jetzt diese Dreieck um 180-Grad auf der Y-Achse drehen würden, so dass wir sie "von hinten" sehen würden, so würde das erste Dreieck nicht gezeichnet werden. Deshalb sind die Vertices in unserem Würfel alle im Uhrzeigersinn angeordnet, so dass jeweils die hinteren Dreieck nicht gerendert werden.
.RenderState.CullMode = Cull.CounterClockwise
Listing 2: Den Cull-Modus umstellen
Den Culling-Modus können wir über die CullMode-Eigenschaft des RenderState-Objekts unseres Devices setzen. Mit Cull.Clockwise können wir Direct3D übringends auch dazu bringen sämtliche Dreiecke im Uhrzeigersinn zu ignorieren, doch normalerweise sollte die Standardeinstellung CounterClockwise genügen.
Auch Cull.None findet z.B. beim Rendern von (halb-)durchsichtigen Körpern seine Verwendung, sollte aber nicht einfach aus Faulheit die Vertices richtig zu ordnen verwendet werden. Auf diese Weise können Sie gut und gerne 40% der Rechenleistung verschwenden, was bei schnellen Rechnern zwar häufig nicht viel ausmacht, aber langsame Rechner ins Ruckeln treibt...
Vertexbuffer
Ein Vertexbuffer hat die Funktion Vertices zu speichern (wer hätte das gedacht?). Zwar gibt es auch die Möglichkeit Vertices aus einem Array zu rendern, allerdings sind VertexBuffer schneller, da Direct3D hier kontrollieren kann, wo die Daten gespeichert werden. Im glücklichsten Fall (der gar nicht so selten vorkommt) landen die Vertices im Speicher der Grafikkarte so dass sie nicht jedes mal über den AGP-Bus transferiert werden müssen. Das Erstellen eines VertexBuffers ist relativ einfach:
Dim cube() As CustomVertex.PositionColored Try p_vbCube = New VertexBuffer(GetType(CustomVertex.PositionColored), _ 36, p_D3DDevice, 0, _ CustomVertex.PositionColored.Format, Pool.Managed) cube = DirectCast(p_vbCube.Lock(0, LockFlags.None), _ CustomVertex.PositionColored()) Catch exp As Exception Return False End Try ' Hier kommt der Code für den Würfel hin... p_vbCube.Unlock()
Listing 3: Einen Vertexbuffer erstellen
Der Konstruktor des Vertexbuffers verlangt zum einen das Format unserer Vertices (es können nur Vertices mit dem gleichen Typ in einem Vertexbuffer gespeichert werden), welches wir mit GetType abfragen können. Anschließend kommt ein Parameter für die Anzahl der Vertices, einen für unser Device, einen für verschiedene andere Einstellungen (die wir nicht brauchen) und noch einen für das Vertexformat. Mit Pool.Managed geben wir an, dass wir Direct3D überlassen wollen, wo es die Daten nun wirklich hintut. Damit wir den Inhalt des VertexBuffers verändern können, müssen wir ihn zuerst sperren. Dies geht über die Lock-Funktion, die uns ein Array von der Größe des gesperrten Bereichs (bei uns ist das alles) zurückgibt.
Wenn wir mit den Veränderungen fertig sind, müssen wir den Buffer zuerst wieder entsperren, da wir ansonsten beim Rendern einen Fehler erhalten würden. Und schon ist unser Würfel in der Kiste :)
Matrizen
Matrizen sind ein hochkompliziertes mathematisches Thema, weswegen ich hier nicht genauer darauf eingehe, wie diese funktioniert. Für alle, die etwas damit anfangen können, Direct3D verwendet hauptsächlich 4x4-Matrizen. Was allerdings viel wichtiger ist, als zu wissen wie Matrizen mathematisch funktionieren, ist wie und wofür wir sie einsetzen können: den mathematischen Kram überlassen wir Direct3D.
Die drei wichtigsten Matrizen sind die World-, View- und die Projection-Matrix, die hier im Kurzen vorgestellt werden sollen.
Die World-Matrix:
Die World-Matrix verändert, wie man aus dem Namen vielleicht deuten kann, wie unsere 3D-Welt angezeigt wird. Mit der World-Matrix können wir z.B. unseren Würfel verschieben, drehen, vergrößern und manches mehr. Hier ein Stück aus dem Beispiel-Code zu diesem Tutorial:
cubeangle += CSng((Environment.TickCount - lastFrameUpdate) / 1000) If cubeangle >= Math.PI * 2 Then cubeangle = 0 p_MatCube1 = Matrix.Multiply(Matrix.RotationY(cubeangle), _ Matrix.RotationX(cubeangle))
Listing 4
Die letzten zwei Zeilen sind denke ich mal die Interessantesten: hier wird eine neue Matrix aus zwei Matrizen erstellt. Die erste rotiert um einen bestimmten Winkel auf der Y-Achse, die zweite rotiert um den selben Winkel auf der X-Achse. Hier befinden sich schon zwei Fallen. Zum ersten ist es im Gegensatz zur normalen Mathematik nicht egal, in welcher Reihenfolge wir multiplizieren d.h. wenn wir die Rotations-Matrix für die X-Achse mit der der Y-Achse multiplizieren würden, würden wir ein komplett anderes Ergebnis als im oberen Beispiel erhalten. Zweitens werden Winkel in Direct3D im Bogenmaß und nicht in Grad angegeben. Wer damit nichts anzufangen weiß, der kann die einfache Formal Winkel in Grad * (Math.PI / 180) verwenden.
Die ersten beiden Zeilen im obigen Code sorgen nur dafür, dass der Würfel sich richtig dreht. Wenn wir hier einen beliebigen konstanten Wert verwendet hätten, so würde sich der Würfel auf verschieden schnellen Rechnern unterschiedlich schnell drehen. Deswegen wird hier dafür gesorgt, dass sich der Würfel in der Sekunde und einen bestimmten Wert (1) dreht. Falls wir bei 360° (PI * 2) angekommen sind, wird der Würfel zurück auf 0° gesetzt.
Hier noch eine Reihe weiterer Funktionen für die Verwendung mit der World-Matrix:
Name der Funktion | Verwendungsmöglichkeit |
Multiply | Kombiniert zwei Matrizen miteinander. Wie schon gesagt ist die Reihenfolge nicht beliebig. |
RotateAxis | Erstellt eine Matrix für eine beliebige Drehung, die über einen Richtungsvektor angegeben werden muss. |
RotateX | Rotiert um die X-Achse |
RotateY | Rotiert um die Y-Achse |
RotateZ | Rotiert um die Z-Achse |
Scale | Vergrößert alles um einen bestimmten Faktor |
Translate | Verschiebt Objekte beliebig im Raum. |
Identity | Beinhaltet eine Matrix die keinerlei Veränderungen vornimmt (also praktisch leer ist, mathematisch stimmt dies mal wieder nicht :) |
Das man mit dem Kombinieren von Matrizen nette Ergebnisse erreichen kann zeigt sich beim zweiten Würfel in Beispiel zu diesem Projekt. Er dreht sich sowohl um sich selbst aber auch um einen anderen Würfel in der Mitte. Dieser Effekt wird nicht durch das Erstellen eines zweiten Würfels erreicht, sondern einfach dadurch, dass der Würfel mit einer anderen World-Matrix gerendert wird. Der Code zum Erstellen dieser Matrix sieht so aus:
p_MatCube2 = Matrix.Multiply(Matrix.RotationY(cubeangle), _ Matrix.Translation(-5, 0, 0)) p_MatCube2 = Matrix.Multiply(p_MatCube2, Matrix.RotationY(cubeangle))
Listing 5: Der zweite Würfel
Die View-Matrix:
Die View-Matrix definiert praktisch eine Kamera mit der wir in unsere 3D-Welt hineinschauen können. Diese Kamera können wir beliebig durch die 3D-Welt bewegen. Hier der Code aus dem Beispiel der die View-Matrix erstellt:
.Transform.View = Matrix.LookAtLH(New Vector3(0, 0, -15), _ New Vector3(0, 0, 0), _ New Vector3(0, 1, 0))
Listing 6: Die View-Matrix einrichten
Der erste übergebene Vektor gibt die Position an, der zweite den Punkte auf den die Kamera schaut. Mit dem dritten lässt sich viel Mist anstellen: z.B. die 3D-Welt auf dem Kopf rendern. Der Vektor gibt an, wo sich im Bild oben befindet. Der beste Wert ist (0, 1, 0) beidem keinerlei Veränderungen vorgenommen werden.
Die Projection-Matrix:
Die Projection-Matrix verändert noch einige "Eigenschaften" der Kamera: den Sichtwinkel und die Entfernung bis in die wir sehen können:
.Transform.Projection = Matrix.PerspectiveFovLH(Math.PI / 4, 4 / 3, 1, 100)
Listing 7: Die Projection-Matrix einstellen
Die ersten beiden Parameter kümmern sich um den Sichtwinkel, die letzten beiden ab welchen Entfernungen von der Kamera Vertices gerendert werden sollen (hier von 1 bis 100).
Der Tiefenbuffer
Schon im letzten Teil haben wir gesehen, wie wir einen Tiefenbuffer initialisieren können. Dessen Bedeutung blieb jedoch im Dunkeln. Der Depth-Buffer hat die Aufgabe zu verhinden, dass Körper, die eigentlich hinter einem anderen liegen, trotzdem davor gezeichnet werden. So lustig das auch klingt, so problematisch ist es auch. Ohne der Tiefenbuffer müssten wir die Körper alle von hinten nach vorne zeichnen, was bei großen Welten ein nicht zu unterschätzendes organisatorisches Problem ergeben würde.
Abbildung 9: Ein Grafikfehler, der ohne Z-Buffer auftreten kann
Der Tiefenbuffer speichert für jeden Pixel die Tiefe des Pixels auf der Z-Achse. Wenn wir jetzt ein weiterentferntes Objekt zeichnen, überprüft der Tiefenbuffer zuerst, ob sich an dieser Stelle schon ein Pixel befindet. Nur wenn das Objekt sich auch wirklich davor befindet wird der Pixel überzeichnet. Hier ein Bild des Beispielprojekts, bei dem der Z-Buffer ausgeschaltet ist:
Obwohl sich der zweite Würfel hinter dem anderen befindet, wird er davor gezeichnet. Bei angeschaltetem Z-Buffer wäre das nicht passiert!
Vor dem Rendern
Bevor wir zu dem Code kommen, der unseren Würfel auf den Bildschirm bringt, will ich noch kurz zwei weitere Änderungen erklären:
With p_D3DDevice .RenderState.Lighting = False .RenderState.ZBufferEnable = True .Transform.View = Matrix.LookAtLH(New Vector3(0, 0, -15), _ New Vector3(0, 0, 0), _ New Vector3(0, 1, 0)) .Transform.Projection = Matrix.PerspectiveFovLH(Math.PI / 4, 4 / 3, 1, 100) .Transform.World = Matrix.Identity End With
Listing 8
Dieser Teil folgt direkt hinter dem Erstellen des Device. Zuerst wird Lighting ausgeschaltet, da wir im Moment kein Lighting verwenden. Danach folgt das Anschalten des Z-Buffers, damit wir die weiter oben genannten Probleme umgehen können. Anschließend werden die View- und die Projectionmatrix eingestellt (und von dort an unverändert gelassen), die Worldmatrix erhält nur einen Standardwert.
Ein weiterer neuer Teil sind die Funktionen UpdateFrame und RenderFrame. UpdateFrame stellt die verschiedenen Matrizen für die beiden Würfel ein und zählt nebenbei die Geschwindigkeit unseres Programms in Frames per Second (FPS), also Bildern in Sekunden. RenderFrame zeichnet die beiden Würfel auf den Bildschirm. Diese beiden Funktionen werden dann in einer Endlosschleife immer wieder aufgerufen. Dabei wird jedesmal der Würfel etwas weitergedreht und das Bild neu gezeichnet. Die FPS geben in diesem Sinn also an, wie oft die Render-Funktion in einer Sekunde aufgerufen wird.
Public Sub UpdateFrame() Static cubeangle As Single Static lastFrameUpdate As Integer Try cubeangle += CSng((Environment.TickCount - lastFrameUpdate) / 1000) If cubeangle >= Math.PI * 2 Then cubeangle = 0 p_MatCube1 = Matrix.Multiply(Matrix.RotationY(cubeangle), _ Matrix.RotationX(cubeangle)) p_MatCube2 = Matrix.Multiply(Matrix.RotationY(cubeangle), _ Matrix.Translation(-5, 0, 0)) p_MatCube2 = Matrix.Multiply(p_MatCube2, Matrix.RotationY(cubeangle)) If (Environment.TickCount() - p_LastFPSCheck >= 1000) Then p_LastFPSCheck = Environment.TickCount() p_FPS = p_FrameCount Console.WriteLine(p_FPS) p_FrameCount = 0 End If p_FrameCount += 1 Catch Finally lastFrameUpdate = Environment.TickCount End Try End Sub
Listing 9
Die FPS-Zählroutine ist einfach zu verstehen. Bei jedem Update wird ein Zähler um eins nach oben gesetzt und kontrolliert wieviel Zeit seit der letzten Ausgabe vergangen ist. Nach einer Sekunde wir der Counter ausgegeben und p_LastFPSCheck die aktuelle Zeit gesetzt.
Die Render-Funktion
Die RenderFrame-Funktion zeichnet unsere beiden Würfel mit dem Device-Objekt auf den Bildschirm.
Public Sub RenderFrame() Try If Not Init Then Return With p_D3DDevice .Clear(ClearFlags.ZBuffer Or ClearFlags.Target, Color.Black, 1, 0) .BeginScene() .SetStreamSource(0, p_vbCube, 0) .VertexFormat = CustomVertex.PositionColored.Format .Transform.World = p_MatCube1 .DrawPrimitives(PrimitiveType.TriangleList, 0, 12) .Transform.World = p_MatCube2 .DrawPrimitives(PrimitiveType.TriangleList, 0, 12) .EndScene() .Present() End With Catch End Try End Sub
Listing 10
Die Render-Funktion hat eigentlich immer den gleichen Rahmen: zuerst muss die Clear-Funktion aufgerufen werden, die das alte Bild und den Z-Buffer löscht und mit einer Hintergrundfarbe füllt (hier einfach nur Schwarz). Mit BeginScene wird dann das Rendern des Bilds begonnen, EndScene schließt dieses ab. Present hat dann schließlich nur die Aufgabe, das (im Speicher) gezeichnete Bild auf den Bildschirm zu bekommen.
Der restliche Code ist auch nicht besonderns kompliziert: mit SetStreamSource machen wir unserem Device bekannt, dass wir gerne den Vertexbuffer als Quelle für unseren Würfel verwenden würden. Die VertexFormat-Eigenschaften zeigt dem Device-Objekt, welches Format unsere Vertices haben. Dann kommt der spannende Teil: über Transform.World setzen wir unsere Worldmatrix und rendern dann den Würfel. Die Parameter der DrawPrimitives-Funktion sollten klar sein: TriangleList erzählt dem Device, dass wir eine Dreieckliste verwenden wollen, startVertex bestimmt von wo aus im Vertexbuffer Direct3D die Vertices nehmen soll und primitiveCount gibt an wieviel Polygone gezeichnet werden sollen. Die selbe Nummer ziehen wir dann nocheinmal für den zweiten Würfel ab, so dass diese Grafik entsteht:
Abbildung 10: Der angezeigt Würfel
Verwendung der Engine
Das Verwenden der Engine ist eigentlich ganz einfach. Wir müssen nur UpdateFrame und RenderFrame in einer Endlosschleife aufrufen. Um ganz auf der Höhe der Zeit zu sein, habe ich die Endlosschleife noch in einen anderen Thread verpackt (so können wir uns das "hässliche" DoEvents sparen). So kann sich die Form im Hauptthread um Clicks und sonstiges kümmern, während das Rendern von einem anderen übernommn wird.
' ... If engine.Init Then running = True Else Me.Close() Dim t As New Threading.Thread(AddressOf Render) t.Start() End Sub Private Sub Render() Do While running Try engine.UpdateFrame() engine.RenderFrame() Catch stopexp As Threading.ThreadAbortException Return End Try Loop End Sub
Listing 11
Das Beispielprojekt zu diesem Tutorial herunterladen [11243 Bytes]
Ihre Meinung
Falls Sie Fragen zu diesem Tutorial haben oder Ihre Erfahrung mit anderen Nutzern austauschen möchten, dann teilen Sie uns diese bitte in einem der unten vorhandenen Themen oder über einen neuen Beitrag mit. Hierzu können sie einfach einen Beitrag in einem zum Thema passenden Forum anlegen, welcher automatisch mit dieser Seite verknüpft wird.