Hauptteil

3 Das Programm WaveGen – Entwicklung und Benutzung

  In diesem Kapitel wird die grundlegende Vorgehensweise beim Erstellen einer 3D-Anwendung Schritt für Schritt anhand des Programms WaveGen erläutert. Am Ende wird noch der CodeDOM, die Reflection-API und Delegates von C# bzw. des .NET-Frameworks als Beispiel für die Mächtigkeit dieses Konzeptes erläutert.

Windows-Programmierung

Da in dieser Arbeit davon ausgegangen wird, dass mit Visual Studio 2005 programmiert wird, ist der Einstieg in die Windows-Programmierung nicht sehr schwierig. Die Windows-API (Application-Programming-Interface) stellt alle Schnittstellen und Funktionen zur Verfügung, um ein Windows-Programm zu implementieren. Da es am Anfang relativ kompliziert ist, mit dem Windows-API zu programmieren, stellt das .NET-Framework glücklicherweise einen einfacheren Einstieg dar. Es abstrahiert vom API und macht den (Anfangs-) Quelltext einem Konsolenprogramm sehr ähnlich. Der Einstieg in ein .NET-Windows-Programm ist die Methode Main(), welche in einer beliebigen statischen Klasse stehen kann, selbst auch statisch sein muss und keinen Rückgabewert hat. Wenn man ein Windows-Programm über den Visual-Studio-Wizard erstellt, erscheint folgender Code:

static class Program
{
	/// 
<summary>
	/// The main entry point for the application.
	/// </summary>

	[STAThread]
	static void Main()
	{
		Application.EnableVisualStyles();
		Application.Run(new Form1());
	}
}

Listing 4.1.1 Einfaches Windows-Programm

An dieser Stelle muss man wissen wie Windows auf Benutzereingaben reagiert. Jede Benutzereingabe (Tastatur, Maus, …) wird als Message-Struktur in eine Nachrichten-Schlange eingeordnet, die dann an die einzelnen Anwendungen verteilt wird. Dies ist wichtig da Windows ein Multitasking-Betriebssystem ist und somit mehrere Programme gleichzeitig verwalten muss. Deshalb benutzt jedes Windows-Programm standardmäßig eine Nachrichtenschleife, in der die Windows-Messages angenommen, ausgepackt und verarbeitet werden. Diese Schleife ist in der Methode Application.Run(new Form1()) versteckt. Mit Application greift man auf alle möglichen Methoden zu, die die Anwendung selbst betreffen. Die Klasse Form1 repräsentiert das Fenster, in dem die Anwendung läuft. Alle Fenster werden von der Oberklasse Form abgeleitet. Mit Application.Run(…) wird die Nachrichtenschleife gestartet und das Fenster erzeugt. EnableVisualStyles() erlaubt der Anwendung, die verschiedenen Fenster- und Steuerelement-Designs von Windows XP und Windows Server 2003 zu benutzen.

Da das Programm WaveGen eine Grafikanwendung ist, in der die Bilder so oft wie möglich gerendert werden sollen, wird die Nachrichtenschleife sichtbar gemacht und die Render-Funktion dort ausgeführt. Somit ergibt sich folgender Code (Program.cs):

static class Program
{
	/// 
<summary>
	/// The main entry point for the application.
	/// </summary>

	[STAThread]
		
	static void Main()
	{	
		Form1 frm = new Form1();
                RenderEngine engine = new RenderEngine(frm, frm.getCompileEvent());
		Application.EnableVisualStyles();
			
		// Remove the cursor
		//frm.Cursor.Dispose();
			
		// Show the form if it isn't already done
		frm.Show();
		// Message loop
		while (frm.Created)
		{
			// Render your graphics 
			engine.Render();
				
                        // Make sure that the application processes the messages
			Application.DoEvents();
		}
		Application.Exit();
	}
}

Listing 4.1.2 Nachrichtenschleife mit Render-Funktion (Program.cs, Main())

Die Klasse RenderEngine initialisiert DirectX, DirectInput und alle 3D-Objekte, zeichnet die Objekte in der Methode Render() und verarbeitet die Benutzereingaben.

Das Fenster-Objekt (frm), das dem Konstuktor als erstes Argument übergeben wird, benötigt DirectX, um dort den DirectX-Viewort einzubetten. Außerdem wird dem Konstruktor noch der Event-Handler für das Compile-Event mitgegeben, um ihn an alle Klassen zu verteilen, die ihn benutzen. So kann jede Klasse, die den Event-Handler besitzt entweder ein Event auslösen oder das Event abonnieren. In diesem Fall löst die Konsole (Klasse Console) das Event aus, wenn Enter gedrückt wird. Die Klasse Water3D reagiert darauf, indem sie den Befehl, der auf der Konsole eingegeben wird interpretiert und ausführt.

Die Windows-Messages sind in das Event-Handling von C# eingebettet (Application.DoEvents()).

DirectX-Initialisierung

 

Den Einstieg in die DirectX-Programmierung liefert das so genannte Direct3D-Device. Es stellt die Render-Komponente von DirectX dar und wird somit für jede Operation gebraucht, die sich auf die Direct3D-Pipeline bezieht. Ein Direct3D-Device verwirklicht die Transformationen, die Beleuchtung und Rasterisierung der Bilder. Da man nicht unbedingt auf den Viewport sondern z.B. auch in eine Textur rendern kann spricht man auch häufig allgemein von einer Surface als Render-Ziel. Außerdem speichert das Direct3D-Device die Device-Zustände wie Render-Zustand, Sampler-Zustand und Textur-Zustand und erlaubt ihre Manipulation.

[footnode]Abb. 4.2.1 Zugriff von DirectX auf die Grafik-Hardware[/footnode]

 

Grundsätzlich gibt es zwei verschiedene Arten von Devices:

Ein HAL (Hardware-Abstraction-Layer)-Device greift über den Grafiktreiber direkt auf die Grafikhardware zu und bietet der Anwendung über DirectX eine Reihe von Interfaces und Methoden an, um die Grafik zu rendern.

Das HAL-Device benutzt hardwareunterstützte Rasterisierung und Shading sowie Software- und Hardware-Vertex-Processing. Die von der Hardware unterstützten Funktionen können über DirectX abgefragt werden (Device-Caps).

Das Reference-Device emuliert alle DirectX-Funktionen in der Software und unterstützt daher alle Funktionen von DirectX mit dem Preis, dass es sehr langsam ist. Deshalb sollte man dieses Device nur zu Testzwecken verwenden.

private void initializeGraphics()
{
// Set our presentation parameters
presentParams = new PresentParameters();

if (fullscreen)
{
presentParams.Windowed = false;
	presentParams.BackBufferWidth = 640;
		presentParams.BackBufferHeight = 480;
		presentParams.BackBufferFormat = Format.X8R8G8B8;
	}
	else
	{
		presentParams.Windowed = true;
		presentParams.BackBufferWidth = 0;
		presentParams.BackBufferFormat = Format.X8R8G8B8;
	}
	presentParams.SwapEffect = SwapEffect.Discard;
	presentParams.EnableAutoDepthStencil = true;
	presentParams.AutoDepthStencilFormat = DepthFormat.D16;
		
// Store the default adapter
int adapterOrdinal = Microsoft.DirectX.Direct3D.Manager.Adapters.Default.Adapter;
			
	CreateFlags flags = CreateFlags.SoftwareVertexProcessing;

// get capabilities of hardware device to check if all features we // use are implemented
Caps caps = Microsoft.DirectX.Direct3D.Manager.GetDeviceCaps(adapterOrdinal, Microsoft.DirectX.Direct3D.DeviceType.Hardware);

// do we support hardware vertex processing?
if (caps.DeviceCaps.SupportsHardwareTransformAndLight)
{
// Replace the software vertex processing
		flags = CreateFlags.HardwareVertexProcessing;
		logfile.WriteLine("Transform and Lighting supported");
}
/...
// Create our device
device = new Microsoft.DirectX.Direct3D.Device(adapterOrdinal, Microsoft.DirectX.Direct3D.DeviceType.Hardware, form, flags, presentParams);
// ...
}

Listing 4.2.1 Initialisierung von DirectX (RenderEngine.cs, initializeGraphics())

Beim Erzeugen des Devices geht man folgendermaßen vor:

Setzen der Parameter für die Fenster-Charakteristiken (PresentParameters).

Speichern des Standard-Grafikadapters.

Überprüfen der Fähigkeiten des Grafikadapters und Setzen der entsprechenden Flags.

Erzeugen des HAL-Devices mit Angabe des Adapters, des Device-Typs (HAL), des Fensters, der Flags und der Present-Parameter.

WaveGen protokolliert in einem Log-File (Logs\logs.txt), ob die benötigten Fähigkeiten von der Hardware unterstützt werden. Wenn dies nicht der Fall ist, startet das Programm zwar trotzdem häufig, jedoch werden die entsprechenden Effekte dann nicht angezeigt.

Das Device-Objekt wird nun allen Klassen mitgegeben, die DirectX-Operationen ausführen. Dies sind alle Klassen, die von der Oberklasse Object3D abgeleitet sind sowie die WaveGen-Fenster-Klassen (StatusWindow, Console und CodeWindow).

Die Methode OnDeviceReset() wird jedes Mal aufgerufen, wenn das Device von außen

verändert wurde (Fenstergröße verändert, Vollbildmodus, …), sowie direkt nach initializeGraphics(). Ersteres erfolgt über den Event-Handler DeviceReset, zweiteres wird manuell aufgerufen. Hier befinden sich alle Initialisierungen, die den Render-Zustand betreffen und nur einmal gesetzt werden müssen bzw. die neu gesetzt werden müssen, sobald das Device zurückgesetzt wird.

private void OnDeviceReset(object sender, EventArgs e)
{
device = (Microsoft.DirectX.Direct3D.Device)sender;
device.Transform.Projection = Matrix.PerspectiveFovLH((float)Math.PI / 4, device.Viewport.Width / device.Viewport.Height, 1.0f, 1000.0f);
		
//restore ressources that are created in Pool.Default
	if (texRend != null)
	{
		texRend.restorePoolTextures();
	}
		
// Turn off culling, so we see the front and back of the triangle
	device.RenderState.CullMode = Cull.None;
	
	// Set to Gouraud shading. This is the default for Direct3D.
	//device.RenderState.ShadeMode = ShadeMode.Gouraud;
	//device.RenderState.ShadeMode = ShadeMode.Flat;
			
	// Turn on D3D lighting
	device.RenderState.Lighting = true;
			
	// Turn on the ZBuffer
	device.RenderState.ZBufferEnable = true;

setupLights();
}

Listing 4.2.2 Render-States (RenderEngine.cs, OnDeviceReset(…))

Die Projektionsmatrix wird erstellt wie in Kapitel 2 beschrieben. Bei einer Veränderung der Fenstergröße verändert sich das Bildseitenverhältnis (aspect ratio), weshalb sie jedes Mal neu gesetzt werden muss.

Die Texturen, in die gerendert wird, dürfen nicht im DirectX-Texturmanager (Pool.Managed) erzeugt werden, sondern müssen im Pool.Default ohne Texturmanager auskommen. Deshalb werden bei einem Reset alle Texturen, die im Pool.Default erzeugt wurden neu geladen.

Der Cull-Mode gibt an, welche Seite der Dreiecke nicht gerendert werden soll. Cull.None bedeutet also, dass beide Seiten gezeichnet werden.

RenderState.Lighting schaltet die Beleuchtung an.

Der Z-Buffer wird verwendet.

setupLights() erzeugt drei direktionale Lichtquellen.

Der Code für die Beleuchtung (Vertex-Lighting) ist nicht sehr schwierig und erklärt sich im Prinzip von selbst.

/// 
<summary>
/// set up vertex lighting
/// </summary>

private void setupLights()
{
	System.Drawing.Color col = System.Drawing.Color.White;
    
// set up a material. The material here just has the diffuse and // ambient colors set to white. Note that only one material can be // used at a time. Not needed, when several objects define their // own material
	Material mtrl = new Material();
			
	mtrl.Diffuse = col;
	mtrl.Ambient = col;
	device.Material = mtrl;

	// Do we have enough support for lights?
if((device.DeviceCaps.VertexProcessingCaps.SupportsDirectionalLights) && (device.DeviceCaps.MaxActiveLights > 2))
	{			
		// First light
		device.Lights[0].Type = LightType.Directional;
		device.Lights[0].Diffuse = Color.White;
		device.Lights[0].Direction = new Vector3(0, -1, 0);
		device.Lights[0].Enabled = true;
				
// Second light
		device.Lights[1].Type = LightType.Directional;
		device.Lights[1].Diffuse = Color.White;
		device.Lights[1].Direction = new Vector3(-1, 0, 0);
		device.Lights[1].Enabled = true;

		// Third light
		device.Lights[2].Type = LightType.Directional;
		device.Lights[2].Diffuse = Color.White;
		device.Lights[2].Direction = new Vector3(0, 0, -1);
		device.Lights[2].Enabled = true;
	}
else
	{
		// no light support, just use ambient light
		device.RenderState.Ambient = Color.White;
	}
}

Listing 4.2.3 Initialisierung der Beleuchtung (RenderEngine.cs, setupLights(…))

Statt der direktionalen Lichtquellen kann man auch alle anderen in den Grundlagen beschriebenen Lichtquellen (Point, Spot) benutzen. Falls Lighting nicht von der Hardware unterstützt wird, leuchtet das ambiente Licht die Szene gleichmäßig aus.

Die 3D-Objekte

Matrix-Transformation und -Multiplikation

Setzen der Weltmatrix

Die gesamte Szene (Menge von 3D-Objekten) von WaveGen besteht aus der Oberfläche des Wassers (Klasse Water3D), einer Skybox (Horizont, Klasse Skybox), welche die globalen (unendlich weit entfernten) Reflexionen des Wassers bedingt und der Landschaft (Klasse Landscape) als Beispiel dafür, wie man lokale Reflexionen behandelt und eine Heightmap ausliest. Alle 3D-Objekte werden abgeleitet von der abstrakten Klasse Object3D, welche die grundlegenden Operationen für jedes 3D-Objekt als virtuelle Methoden implementiert. Methoden, die noch implementiert werden müssen, sind als abstrakte Methoden deklariert. So gehört z.B. zu jedem 3D-Objekt eine Weltmatrix, die seine Position im Weltkoordinatensystem bestimmt. Jedes Objekt kann in der Welt bewegt, rotiert und gesetzt werden und ist mit einer Menge von Texturen verknüpft. Auf diese Texturen und Matrizen muss der Effekt-Manager von WaveGen zugreifen, da sich ein Effekt immer auf ein bestimmtes Objekt bezieht. Die restlichen Informationen, die der Effekt-Manager benötigt liefert die Camera-Klasse, welche die View-Matrix abhängig von der Benutzereingabe manipuliert.


Abb. 4.3.1 UML-Diagram der Klasse Object3D mit vererbten Klassen

 

Um ein Objekt zu transformieren stellt DirectX zahlreiche Funktionen zur Verfügung. Möchte man es beispielsweise verschieben, kann man die Methode Matrix.Translation(…) benutzen, welche eine Translationsmatrix generiert, wie sie in Kapitel 2 beschrieben ist. Wenn man diese Matrix als Weltmatrix verwendet, kann man so die Methode setObject(…) definieren, die ein Objekt an eine bestimmte Stelle im Weltkoordinatensystem setzt.

public virtual void setObject(float posX, float posY, float posZ)
{
	pos.X = posX;
	pos.Y = posY;
	pos.Z = posZ;

	worldMatrix = Matrix.Translation(posX, posY, posZ);
}

Listing 4.3.1 Setzen eines Objektes im World-Space (Object3D.cs, setObject(…))

Wenn man nun ein Objekt relativ zu seiner alten Position bewegen möchte, muss man die Translationsmatrix mit der alten Weltmatrix multiplizieren (Matrix.Multiply(…)).

public virtual void moveObject(float transX, float transY, float transZ)
{
	pos.X += transX;
	pos.Y += transY;
	pos.Z += transZ;

// set matrix to translate object
	transMatrix = Matrix.Translation(transX, transY, transZ);

	//translate object to world
	worldMatrix = Matrix.Multiply(worldMatrix, transMatrix);
}

Listing 4.3.2 Bewegen eines Objektes im World-Space (Object3D.cs, moveObject(…))

Jedes 3D-Objekt besitzt die Methode updateObject(). Diese stellt die Render-Methode des Objektes dar und wird in jedem Durchlauf der Nachrichtenschleife ausgeführt. Hier werden alle Render-Operationen ausgeführt, die sich auf das Objekt beziehen (Multi-Texturing, Alpha-Blending, …). Was aber immer geschehen sollte ist die Zuweisung der Weltmatrix an DirectX, um eine Umrechnung der Objektkoordinaten in Weltkoordinaten sicherzustellen. Außerdem werden dadurch Transformationen, die in der Weltmatrix gespeichert sind, angewendet. Dies geschieht mit der Methode SetTransform(…).

public virtual void updateObject()
{
	device.SetTransform(TransformType.World, worldMatrix);
}

Listing 4.3.3 Setzen der Weltmatrix (Object3D.cs, updateObject())

Die Methode updateObject() jedes Objektes wird in der Methode Render() der Klasse RenderEngine aufgerufen. Wenn man der Szene ein neues Objekt (z.B. ein Mesh) hinzufügt, muss man die updateObject()-Methode des Objektes in der Render()-Methode platzieren.

public void Render(){…device.Clear(ClearFlags.Target | ClearFlags.ZBuffer, Color.Black, 1.0f, 0);device.BeginScene();skybox.updateObject();…device.EndScene();…device.Present();Listing 4.3.4 Rendern der Objekte (RenderEngine.cs, Render())

Zunächst wird der Back-Buffer mit schwarzer Farbe gelöscht. Alles was nun zwischen BeginScene() und EndScene() steht, wird in den Back-Buffer gerendert. Deswegen müssen hier alle updateObject()-Methoden aufgerufen werden. Die Methode Present() tauscht dann den Back-Buffer mit dem Front-Buffer, womit der Inhalt des Back-Buffers auf dem Bildschirm dargestellt wird. Die Vorgehensweise zum Zeichnen der Primitive in der Methode updateObject() ist im Kern immer gleich. Es wird ein Vertex-Buffer (evtl. mit zugehörigem Index-Buffer) gefüllt, der die Positionen der Eckpunkte enthält. Diese Daten werden DirectX zum Zeichnen übergeben (DrawPrimitives()). Außerdem werden Pipeline-Zustände gesetzt, Texturen geladen und die Texturen einer Texturstufe zugewiesen.

Die Skybox

Vertex-Buffer, Vertex-Format

Laden von Texturen

Manipulation des Render- und Textur-Zustandes

 

Eine Skybox stellt die Umgebung einer Außenwelt dar. Man geht davon aus, dass diese Umgebung (Horizont) unendlich weit entfernt ist. Realisiert wird die Skybox, indem man um die Kamera einen Würfel mit Landschaftstexturen erstellt. Diese Texturen passen so perfekt zusammen, dass man die Ecken des Würfels nicht erkennen kann. So entsteht der Eindruck einer Sphäre, statt eines Würfels, rund um den Betrachter. Da die Skybox immer gleich weit von der Kamera entfernt sein soll, bewegt sie sich mit dieser in jede Richtung. Weil die Kamera (also auch die Skybox) anders gesteuert wird als die meisten 3D-Objekte, muss die Skybox die entsprechenden Methoden überschreiben.

Da die Skybox nur aus wenigen Eckpunkten und 6 Texturen besteht und keine Index-Buffer benutzt, kann man an ihr gut den Vertex-Buffer und das Vertex-Format erklären. Zunächst wird im Konstruktor der Vertex-Buffer erstellt:

// initialize vertex bufferthis.vb = new VertexBuffer(typeof(CustomVertex.PositionNormalTextured),36, device, 0, CustomVertex.PositionNormalTextured.Format, Pool.Managed);Listing 4.3.5 Erstellen des Vertex-Buffers (Skybox.cs, Konstruktor)

Der Typ des Vertex-Formates und die Anzahl der Vertices muss angegeben werden, damit DirectX weiß, wie viel Speicher für den Vertex-Buffer reserviert werden muss. Das Vertex-Format PositionNormalTextured bedeutet, dass die Position, die Normale und die Texturkoordinaten Tu und Tv der Vertices angegeben werden.

Die Vertices definiert man dann in einem Feld, das diese Vertex-Attribute enthält:

CustomVertex.PositionNormalTextured[] verts = {new CustomVertex.PositionNormalTextured(pos.X + width,pos.Y – width,pos.Z – depth,0.0f,0.0f,1.0f,0.0f,1.0f),new CustomVertex.PositionNormalTextured(pos.X + width,pos.Y + width,pos.Z – depth,0.0f,0.0f,1.0f,0.0f,0.0f),new CustomVertex.PositionNormalTextured(pos.X – width,pos.Y – width,pos.Z – depth,0.0f,0.0f,1.0f,1.0f,1.0f),new CustomVertex.PositionNormalTextured(pos.X – width,pos.Y – width,pos.Z – depth,0.0f,0.0f,1.0f,1.0f,1.0f),new CustomVertex.PositionNormalTextured(pos.X + width,pos.Y + width,pos.Z – depth,0.0f,0.0f,1.0f,0.0f,0.0f),new CustomVertex.PositionNormalTextured(pos.X – width,pos.Y + width,pos.Z – depth,0.0f,0.0f,1.0f,1.0f,0.0f), // back…}Listing 4.3.6 Definition der Vertex-Attribute (Skybox.cs, Konstruktor)

Als Beispiel ist hier die Rückseite der Skybox definiert. Die ersten drei Parameter definieren die Position der Vertices in Abhängigkeit von der Mitte der Skybox. Dann folgen die Normalen (hier: (0, 0, 1)), die nach innen zeigen, und zuletzt die Texturkoordinaten, welche die Textur über die gesamte Fläche zeichnen. Hierbei muss man aufpassen, in welcher Reihenfolge man die Vertices zeichnet, damit die richtige Seite der Textur zu sehen ist.

Nun werden die Texturen geladen:

backTexture = TextureLoader.FromFile(device, Application.StartupPath + @“\..\..\Media\Skybox\Cube_south.tga“);
Listing 4.3.7 Laden der Texturen (Skybox.cs, Konstruktor)

Dies ist die einfachste Methode zum Laden von Textur-Dateien, es gibt noch einige Überladungen dieser Methode, in denen man unter anderem angeben kann, ob die Textur im Pool.Managed (DirectX-Texturmanager) oder Pool.Default erzeugt wird. Standard ist Pool.Managed. Als nächstes werden die Daten in den Vertex-Buffer geschrieben, was folgendermaßen geschieht.

GraphicsStream stm = vb.Lock(0, 0, 0);stm.Write(verts);vb.Unlock();Listing 4.3.8 Schreiben der Vertex-Daten in den Vertex-Buffer (Skybox.cs, Konstruktor)

Es wird ein Grafikstrom erzeugt, indem der Vertex-Buffer gesperrt wird, die Vertex-Daten in den Strom geschrieben und der Vertex-Buffer wieder freigegeben.

In der Methode updateObject() wird nun zunächst das Lighting ausgeschaltet, da durch die drei direktionalen Lichtquellen, die standardmäßig eingeschaltet sind, nur drei Seiten der Skybox zu sehen wären. Dann wird der Z-Buffer ausgeschaltet, weil die Skybox (konzeptionell) unendlich weit entfernt ist und so alle Objekte vor der Skybox gezeichnet werden. All diese Operationen beeinflussen den Zustand der 3D-Pipeline:

public override void updateObject(){device.RenderState.Lighting = false;device.RenderState.ZBufferEnable = false;device.SetTransform(TransformType.World, worldMatrix);device.SetStreamSource(0, vb, 0);device.SamplerState[0].AddressU = TextureAddress.Clamp;device.SamplerState[0].AddressV = TextureAddress.Clamp;device.SamplerState[0].MagFilter = TextureFilter.Linear;device.SamplerState[0].MinFilter = TextureFilter.Linear;device.SamplerState[0].MipFilter = TextureFilter.Linear;device.TextureState[0].TextureCoordinateIndex = (int)TextureCoordinateIndex.PassThru;device.TextureState[0].TextureTransform =TextureTransform.Disable;device.TextureState[0].ColorOperation = TextureOperation.Modulate;device.TextureState[0].ColorArgument1 = TextureArgument.TextureColor;device.TextureState[0].ColorArgument2 = TextureArgument.Diffuse;drawPrimitives();device.RenderState.Lighting = engine.getLight();device.RenderState.ZBufferEnable = true;}Listing 4.3.9 Sampler- und Textur-Operationen (Skybox.cs, updateObject())

Übergabe der Weltmatrix an DirectX (SetTransform(…))

Binden des Vertex-Buffers an den Vertex-Datenstrom des Devices (SetStreamSource())

Setzen der Textur-Sampler-Operationen (SamplerState[0]) für die Textur (erste Stufe), wie Textur-Addressierungsmodus, Magnification-Filter, Minification-Filter und Mip-Map-Filter und Art der Texturkoordinaten-Generierung (hier: Verwenden der beim Vertex-Format angegebenen Koordinaten)

Festlegen der Texturoperationen (Texturtransformation ausschalten, Blend-Operationen). Die ColorOperation beschreibt, wie das ColorArgument1 mit dem ColorArgument2 verrechnet wird um die Farbe der Pixel zu berechnen. In diesem Fall wird die Farbe des Texels (TextureArgument.TextureColor) mit der beim Gouraud-Shading berechneten Farbe (TextureArgument.Diffuse) multipliziert (TextureOperation.Modulate)

Zeichnen der Skybox

Z-Buffer einschalten und Licht auf Ursprungszustand zurücksetzen

Die Methode drawPrimitives() übergibt die geladenen Texturen und die dazugehörigen Primitive an DirectX und zeichnet so die Skybox:

public override void drawPrimitives(){//draw skyboxdevice.SetTexture(0, backTexture);device.DrawPrimitives(PrimitiveType.TriangleList, 0, 2);device.SetTexture(0, rightTexture);device.DrawPrimitives(PrimitiveType.TriangleList, 6, 2);device.SetTexture(0, topTexture);device.DrawPrimitives(PrimitiveType.TriangleList, 12, 2);device.SetTexture(0, bottomTexture);device.DrawPrimitives(PrimitiveType.TriangleList, 18, 2);device.SetTexture(0, leftTexture);device.DrawPrimitives(PrimitiveType.TriangleList, 24, 2);device.SetTexture(0, frontTexture);device.DrawPrimitives(PrimitiveType.TriangleList, 30, 2);}Listing 4.3.10 Zeichnen der Skybox mit ihren Texturen (Skybox.cs, updateObject())

SetTexture(…) füllt die erste Texturstufe mit den entsprechenden Daten. DrawPrimitives(…) zeichnet dann die Primitive. Als Parameter übergibt man, an welcher Stelle im Vertex-Buffer man beginnt und wie viele Dreiecke (Pimitives.TriangleList) ab dann gerendert werden. Man sieht, dass die 6 Seiten der Skybox nach und nach mit ihren dazugehörenden Texturen gezeichnet werden.

Am Beispiel der Skybox wurde gezeigt, wie man ein einfaches Objekt, in diesem Fall ein Quader, erstellt und mit den zugehörigen Texturen zeichnet. Die nächste Lektion zeigt am Beispiel des Wasser-Objektes, wie man einen Index-Buffer verwendet und mehrere Texturen übereinander legt.

 

Die Wasseroberfläche

Erstellen komplexer Objekte (Wasser-Grid) und zugehöriger Texturkoordinaten

Index-Buffer

Environment-Bump-Mapping

Cube-Mapping

Projektives Texture-Mapping

 

Den Kern des Programms WaveGen stellt die Wasseroberfläche dar. Diese bewegt oder formt sich ausgehend von mathematischen Funktionen, die man auf der Konsole eingibt, und stellt die Shader-Effekte dar, die man als HLSL-Datei über ein XML-Interface einbinden kompilieren und laden kann.

Das Drahtgitter der Oberfläche (Grid) ist eine große quadratische Oberfläche, die aus kleinen Quadraten (Quads) zusammengesetzt ist. Diese bestehen wiederum aus 2 Dreiecken. Ein Quad ist die kleinste Einheit der Oberfläche, die sich dementsprechend immer als Ganzes bewegt und nicht weiter teilbar ist. Bewegt werden die Quads nur in y-Richtung (nach oben oder unten), was dann die Bewegung der Wasseroberfläche darstellt. Abbildung 4.3.2 verdeutlicht, wie das Grid zustande kommt. Die Vertices werden in Reihen von links oben nach rechts unten definiert. Durch den Index-Buffer werden dann die Quads festegelegt, indem die Indizes der Vertices, aus denen die 2 Dreiecke eines Quads bestehen, hineingeschrieben werden. Durch Zusammenfügen der Quads im Index-Buffer entsteht dann das Grid.


Abb. 4.3.2 Zeichnen des Grids

Die Quads haben in der Standardeinstellung eine Seitenlänge von 1.0 und die Oberfläche besteht aus 40×40 Quads. Diese Einstellungen kann man ändern, indem man „quads #int_quads“ (Bsp.: „quads 20“) auf der Konsole eingibt, um die Anzahl der Quads einer Seite einzustellen und „xgrid float_seitenlänge“ bzw. „zgrid float_seitenlänge“ (Bsp.: „xgrid 0,5“) für die Seitenlänge eines Quads in x- bzw. z-Richtung. Wichtig ist hierbei das man die Gleitkommazahlen mit einem Komma und nicht mit einem Punkt eingibt. Außerdem kann es natürlich sein, dass die Quads nicht mehr quadratisch sondern rechteckig sind, weil man die x- und z-Seitenlänge separat festlegen kann.

Der Vertex-Buffer wird nicht wie bei der Skybox im Konstruktor (also nur einmal) gefüllt, sondern bei jedem Render-Durchlauf. Dies muss sein, weil sich der Vertex-Buffer ständig ändert, falls man das Argument t (Zeit) bei Eingabe der Funktion zur Wellenberechnung angibt („Math.Sin(x + t)“). In diesem Fall ändert sich bei jedem Render-Durchlauf der y-Wert aller Vertices. Deshalb befinden sich die Methoden zum Schreiben der Vertices in den Vertex-Buffer und zum Binden des Vertex-Buffers an den Vertex-Strom in der Methode drawIndexedPrimitives(), welche in jedem Aufruf von updateObject() ausgeführt wird. (drawIndexPrimitives() ist nicht zu verwechseln mit der DirectX-Methode DrawIndexedPrimitives(…), die im Folgenden erklärt wird). Somit folgt für die Methode drawIndexedPrimitives():

public override void drawIndexedPrimitives(){device.SetTransform(TransformType.World, worldMatrix);// counter for verticesint i = 0;//lock vertex bufferstm = vbIndexed.Lock(0, 0, 0);…Listing 4.3.11 Vertex-Buffer wird ständig erneuert (Water3D.cs, drawIndexedPrimitives())

Der erste Teil der Methode birgt nicht viel Neues, außer der schon beschriebenen Tatsache, dass der Vertex-Buffer zum Schreiben freigegeben wird. Danach werden die Vertices der Wasseroberfläche mit ihren Attributen definiert.

// calculate position of verticesx = pos.X – ((quad_num * xgrid) / 2.0f);z = pos.Z + ((quad_num * zgrid) / 2.0f);// points of function coordinate systemxCoord = pos.X – (quad_num / 2.0f);zCoord = pos.Z – (quad_num / 2.0f);// texture coordinatesxTex = 0.0f;zTex = 0.0f;for (int zDir = 0; zDir < quad_num; zDir += 1){for (int xDir = 0; xDir < quad_num; xDir += 1){// just draw vertices once, the rest does index buffer // take xCoord and zCoord as argument (function // coordinate system)heightmap[xDir, zDir] = pos.Y + (float)compiler.plotFunction(xCoord, zCoord, counter);indexedVerts[i].X = x;indexedVerts[i].Z = z;indexedVerts[i].Y = heightmap[xDir, zDir];indexedVerts[i].Tu = xTex / ((float)quad_num * xgrid);indexedVerts[i].Tv = zTex / ((float)quad_num * zgrid);x += xgrid; // side length of one quadxCoord += xStep; // one unit in function coordinate // systemxTex += xgrid; // texure coordinatesi++;}z -= zgrid;zCoord += zStep;zTex += zgrid;xTex = 0.0f;x = pos.X – ((quad_num * xgrid) / 2.0f);xCoord = pos.X – (quad_num / 2.0f);}Listing 4.3.12 Füllen der Vertex-Attribute (Water3D.cs, drawIndexedPrimitives())

In den ersten beiden Zeilen wird die Startposition links oben anhand der Menge der Quads (quad_num), der Quad-Seitenlänge (xgrid, zgrid) und des Mittelpunktes der Fläche berechnet.

Die Variablen xCoord und zCoord speichern die Werte des Funktions-Koordinatensystems welches unabhängig von der Breite der Quads ist. Es erstreckt sich in x- und z-Richtung von quad_num/2 bis +quad_num/2. Jedes Quad stellt eine Einheit dar. Die Länge einer Einheit kann man jedoch unabhängig von der Quad-Seitenlänge angeben. Diese wird in xStep und zStep gespeichert und kann mit den Befehlen „xstep float_seitenlänge“ und „zstep float_seitenlänge“ (Bsp.: „xstep 0,5“) geändert werden. So kann man beispielsweise die Wellenausbreitung verfeinern. Eine gröbere Einstellung empfiehlt sich nur selten. Gibt man z.B. „xstep 3“ ein (ungefähr PI) und eine Sinusfunktion als Wellenfunktion („Math.Sin(x+t)“), sieht man, dass die Wellen im Abstand von 2*PI gleich schwingen, was nicht sehr schön aussieht.

Um die Texturkoordinaten zu berechnen braucht man zusätzliche Hilfsvariablen (xTex und zTex), da diese keine negativen Werte zulassen. In einer doppelten For-Schleife über der Anzahl der Quads werden dann die Koordinaten in das Vertex-Feld geschrieben. Die y-Koordinate wird aus der zuvor kompilierten Funktion berechnet, welche die Parameter xCoord (x-Koordinate des Funktions-Koordinatensystems), zCoord (z-Koordinate des Funktions-Koordinatensystems) und t (Zeit) berücksichtigt. Die Daten werden zuvor in eine Heightmap geschrieben, damit man diese evtl. bei einer Weiterentwicklung des Programms an den Vertex-Shader übergeben kann. Bei den Texturkoordinaten bekommt jedes Vertex seine x- und z-Position, umgerechnet ins Texturkoordinatensystem (Tu und Tv im Bereich 0.0 bis 1.0), so dass die Textur über die gesamte Oberfläche gespannt wird.

Nach der For-Schleife in x-Richtung werden dann die Variablen für die z-Richtung heruntergesetzt und die Variablen für die x-Richtung zurückgesetzt, da eine neue Vertex-Zeile unter der alten definiert wird.

Weil nun jedes Vertex nur einmal in den Vertex-Buffer geschrieben wurde, muss der Index-Buffer definieren, wie sich die Oberfläche aus den Vertices zusammensetzt.

private void initIndexVertexBuffer(){// counter of indicesint j = 0;indexedVerts = new CustomVertex.PositionNormalTextured[quad_num * quad_num];index = new int[quad_num * quad_num * 6];vbIndexed = new VertexBuffer(typeof(CustomVertex.PositionNormalTextured), quad_num * quad_num, device, Usage.None, WaterVertex.Format, Pool.Managed);ib = new IndexBuffer(typeof(System.Int32), quad_num * quad_num * 6, device, Usage.None, Pool.Managed);for (int z = 0; z < quad_num – 1; z++){for (int x = 0; x < quad_num – 1; x++){// first triangle of quad (counter clockwise)index[j] = z * quad_num + x;index[j + 1] = z * quad_num + x + 1;index[j + 2] = (z + 1) * quad_num + x;j += 3;// second triangle of quad (counter clockwise)index[j] = z * quad_num + x + 1;index[j + 1] = (z + 1) * quad_num + x + 1;index[j + 2] = (z + 1) * quad_num + x;j += 3;}}// Lock the IndexBufferstmInd = ib.Lock(0, 0, LockFlags.None);stmInd.Write(index);// And unlock the bufferib.Unlock();}Listing 4.3.13 Schreiben des Index-Buffers (Water3D.cs, initIndexVertexBuffer())

In der Methode initIndexVertexBuffer() werden der Index- und der Vertex-Buffer initialisiert. Diese müssen nur dann neu initialisiert werden, wenn sich ihre Größe, also die Anzahl der Quads ändert (nach dem Befehl „quads“ und im Konstruktor). Außerdem kann der Index-Buffer gefüllt werden, denn die Zusammensetzung der Vertices zum Grid ist nicht abhängig von der Position der Vertices. Der Index-Buffers ist sechsmal so groß wie die Anzahl der Quads, weil jedes Quad aus zwei Dreiecken (sechs Vertices) besteht. Nun werden wieder in einer doppelten For-Schleife die Indices der Vertices gespeichert, die sich zu den Quads zusammensetzen. Das Schreiben des Index-Feldes in den Index-Buffer funktioniert dann analog zum Vertex-Buffer. Als letztes muss DirectX noch angewiesen werden, den Index-Buffer zu verwenden:

stm.Write(indexedVerts);vbIndexed.Unlock();device.Indices = ib;device.SetStreamSource(0, vbIndexed, 0);device.VertexFormat = CustomVertex.PositionNormalTextured.Format;device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, quad_num * quad_num, 0, quad_num * quad_num * 2);Listing 4.3.14 Benutzen des Index-Buffers (Water3D.cs, updateObject())

Hier genügt es, den vorher gefüllten Index-Buffer der Variablen device.Indices zuzuweisen und als Zeichenbefehl DrawIndexedPrimitives(…) zu verwenden.

Nachdem die Geometrie der Oberfläche feststeht, wird nun das Aussehen des Wassers durch das Texture-Mapping bestimmt.

Wie schon in den Grundlagen dargestellt, können Texturen durch bestimmte Operationen überblendet werden. Dies ist in diesem Fall notwendig, weil die Effekte, die das Wasser darstellen, in mehreren Texturen gespeichert sind.

/// <summary>/// create waves in vertex buffer/// </summary>public override void updateObject(){device.RenderState.AlphaBlendEnable = true;// uncomment to make water transparent//device.RenderState.SourceBlend = Blend.SourceColor;//device.RenderState.DestinationBlend = // Blend.InvSourceColor;// reflection color of watermtrl.Diffuse = System.Drawing.Color.White;mtrl.Ambient = System.Drawing.Color.White;device.Material = mtrl;Matrix matView = camera.getViewMatrix();Matrix matProj = camera.getProjectionMatrix();Matrix mat = Matrix.Identity;Matrix matTexScale = Matrix.Identity;//Inverse of view to get from camera into world spaceMatrix matViewInv = Matrix.Invert(matView);matViewInv.M41 = 0.0f;matViewInv.M42 = 0.0f;matViewInv.M43 = 0.0f;matViewInv.M44 = 0.0f;Listing 4.3.15 Mult-Texturing (Water3D.cs, updateObject())

Zunächst wird das Alpha-Blending eingeschaltet. Dieses bewirkt, dass Objekte transparent dargestellt werden. Dieses Alpha-Blending nennt man auch FrameBufferAlphaBlending. Es arbeitet unterschiedlich zu den anderen Alpha-Blending Varianten (Vertex-Alpha, Material-Alpha, Texture-Alpha), die sich immer auf das aktuelle Primitiv beziehen und keinen Effekt auf andere Primitive haben. Frame-Buffer-Alpha-Blending bestimmt, wie die Pixel des aktuellen Primitivs mit den derzeitigen Pixeln des Frame-Buffers überblendet werden. Man bestimmt eine Quell (Source)- und eine Ziel (Destination)-Farbe, wobei die Quellfarbe die Farbe des aktuellen (transparenten) Primitivs ist und die Zielfarbe die Farbe im Frame-Buffer darstellt. Die hier eingestellten Werte (auskommentiert) bewirken eine lineare Überblendung. Viele andere Blending-Faktoren setzen einen Alpha-Wert in der Textur voraus. Allerdings muss man die Objekte für eine korrekte Darstellung (man sieht alles hinter dem transparenten Objekt) von hinten nach vorne rendern.

Dann wird die Reflexionsfarbe des Wassers festgelegt und einige Matrizen definiert, die im weiteren Verlauf zur Texturtransformation benutzt werden.

// enable environment bump mapping in softwaremakeBumpMapMatrix();device.SetTexture(0, bumptexture2);device.SamplerState[0].MagFilter = TextureFilter.Linear;device.SamplerState[0].MinFilter = TextureFilter.Linear;device.SamplerState[0].MipFilter = TextureFilter.Linear;device.SamplerState[0].AddressU = TextureAddress.Wrap;device.SamplerState[0].AddressV = TextureAddress.Wrap;device.TextureState[0].TextureCoordinateIndex = (int)TextureCoordinateIndex.PassThru;device.TextureState[0].ColorOperation = TextureOperation.BumpEnvironmentMap;device.TextureState[0].ColorArgument1 = TextureArgument.TextureColor;device.TextureState[0].ColorArgument2 = TextureArgument.Diffuse;device.TextureState[0].BumpEnvironmentMaterial00 = bumpMapMatrix.M11;device.TextureState[0].BumpEnvironmentMaterial01 = bumpMapMatrix.M12;device.TextureState[0].BumpEnvironmentMaterial10 = bumpMapMatrix.M21;device.TextureState[0].BumpEnvironmentMaterial11 = bumpMapMatrix.M22;Listing 4.3.16 Bump-Mapping (Water3D.cs, updateObject())

Nun beginnt das Multi-Texturing auf der ersten Texturstufe mit dem Bump-Environment-Mapping. Die nächste Stufe wird also als Environment-Map angesehen, auf welche die EMBM-Operationen angewendet werden.

Die Methode makeBumpMapMatrix() erstellt die Matrix zur Variation der Texturkoordinaten, die in einer Bump-Map gespeichert sind. Die Bump-Map wurde zuvor mit der Methode CreateBumpMap(…) erstellt und der Variablen bumptexture2 zugewiesen. Diese wird dann der ersten Texturstufe übergeben. Durch die Variationen mit der Matrix entsteht eine Bewegung.

Nachdem die üblichen Filter-Einstellungen gemacht wurden, setzt man noch fest, dass als Texturkoordinaten die Vertex-Texturkoordinaten verwendet werden (TextureCoordinateIndex.PassThru). Dies ist auch die Standardeinstellung. Bei den anderen Texturstufen benötigt man hier jedoch Methoden zur Texturkoordinaten-Generierung.

Nun wird für die erste Texturstufe festgelegt, dass Bump-Environment-Mapping benutzt wird (TextureOperation.BumpEnvironmentMap), die Farbargumente festgelegt und die Bump-Map-Matrix-Operationen angewendet.

 

 

 

 

Exkurs: Planare Spiegelung

Da lokale Reflexionen nicht mit einer Cube-Map dargestellt werden können, muss man sich einer anderen Technik bedienen: Der planaren Spiegelung. Leider funktioniert diese Technik nur für flache Objekte, wobei eine Wasseroberfläche normalerweise flach genug ist, um durch eine reflektierende Ebene approximiert zu werden. Der Effekt ist betrachterabhängig und wird in zwei Phasen erzeugt.

In der ersten Phase wird die Umgebung der Wasseroberfläche durch eine spezielle Reflexionsmatrix reflektiert. Da eine ungestörte Wasseroberfläche parallel zum Boden verläuft, hat die Ebene, welche die Wasseroberfläche approximiert, eine konstante Normale von (0, 1, 0). Die Reflexion besteht daher in einer Skalierung von -1 auf der y-Achse (was einem Umkippen der y-Achse entspricht). Zusätzlich muss es berücksichtigt werden, wenn die Oberfläche nicht durch den Ursprung verläuft. Mit der gegebenen Höhe h ergibt sich somit die Reflexionsmatrix:

 

Dann wird die Umgebung mit der neuen, konkatenierten View-Matrix in eine 2D-Textur gerendert:

, mit als View-Matrix.

In der zweiten Phase wird dann die Textur mit projektivem Texture-Mapping auf die Oberfläche projiziert.

Konzeptionell geschieht hier das Gegenteil einer Kamera: Statt das Geschehen durch eine perspektivische Projektion mit einer Kamera aufzunehmen, wird eine Textur auf die Oberfläche projiziert, indem eine Matrix benutzt wird, die der View-Matrix sehr ähnlich ist. Beide wenden die gleichen Operationen auf die Vertices an. Der Unterschied liegt in der Art, wie die resultierenden Koordinaten benutzt werden. Die Kamera verwendet die transformierten und projizierten Vertex-Positionen, um ein Primitiv auf den Bildschirm zu rendern. Projektives Texture-Mapping benutzt sie, um eine Textur zu indizieren.

Allgemeines projektives Texturing benutzt einen beweglichen Projektor, der beliebig in der Szene positioniert werden kann. In diesem speziellen Fall sind die Position und Orientierung des Projektors gleich denen der Kamera. Da sich beide die View- und Projection-Matrix teilen, wird das Problem vereinfacht.

Daher ist auch die Mathematik, die hinter dem projektiven Texture-Mapping steckt, relativ einfach und orientiert sich an der klassischen Transformations-Pipeline. Zunächst wird die Vertex-Position mit der View- und Projektions-Matrix multipliziert und befindet sich danach im Clipping-Space.

An dieser Stelle muss eine Remapping-Operation stattfinden, die typisch für projektives Texturing ist. Der Clipping-Space hat eine Spanne von -1.0 bis 1.0 in x-Richtung und 1.0 bis -1.0 in y-Richtung. Da aber die in den Clipping-Space transformierte Vertex-Position als Texturkoordinate verwendet wird, muss eine Transformation in den Bereich 0.0 bis 1.0 stattfinden. Dies geschieht durch die Division der x- bzw. y-Koordinate durch 2.0 bzw. -2.0 und Addition von 0.5 zu dem Ergebnis. Diese Operation kann durch folgende Matrix beschrieben werden:

 

So werden die projektiven Texturkoordinaten mit folgender Matrix generiert:

, mit und als Projektions- und View-Matrix.

Die resultierenden projektiven Texturkoordinaten zeigen ein Verhalten, das normalerweise bei Texturkoordinaten nicht vorkommt. Ihre dritte, homogene Koordinate q ist nicht automatisch 1 und kann deshalb nicht ignoriert werden. Dies folgt aus der Projektionsmatrix, die in der Standard-Transformations-Pipeline zu den homogenen w-Koordinaten führt. In der gleichen Weise, wie x- und y-Koordinate durch w geteilt wird (perspektivische Division), müssen die Texturkoordinaten durch q geteilt werden. Um dies zu erreichen verwendet man im Shader-Programm die Funktion tex2Dproj(…). DirectX teilt man dies bei den Textur-Transformationsflags mit (TextureTransform.Projected). Die Implementierung ist nun nicht mehr schwierig.


Zuerst wird jedoch nur die Anwendung des projektiven Texturings behandelt, weil dies die nächste Texturstufe darstellt. Das Rendern der gespiegelten Landschaft in die Textur folgt im Kapitel über die Landschaft.

// set mirror texture of landscape and project it on water// its environment map for bump mapping so this texture has // little wavesdevice.SetTexture(1, getEnvTexture()); // get Texture with // landscapedevice.SamplerState[1].AddressU = TextureAddress.Clamp;device.SamplerState[1].AddressV = TextureAddress.Clamp;device.SamplerState[1].AddressW = TextureAddress.Clamp;device.TextureState[1].TextureCoordinateIndex = (int)TextureCoordinateIndex.CameraSpacePosition;device.TextureState[1].TextureTransform = TextureTransform.Count3 | TextureTransform.Projected;device.TextureState[1].ColorOperation = TextureOperation.SelectArg1;device.TextureState[1].ColorArgument1 = TextureArgument.TextureColor;device.TextureState[1].ColorArgument2 = TextureArgument.Diffuse;matTexScale.M11 = 0.5f;matTexScale.M22 = -0.5f;matTexScale.M33 = 0.5f;matTexScale.M44 = 1.0f;matTexScale.M14 = 0.0f;matTexScale.M24 = 0.0f; matTexScale.M34 = 0.0f;matTexScale.M41 = 0.5f;matTexScale.M42 = 0.5f;matTexScale.M43 = 0.5f;// Calculate matrix chain for projectionmat = Matrix.Multiply(matProj, matTexScale);device.SetTransform(TransformType.Texture1, mat);Listing 4.3.17 projektive Texture-Map als Environment-Map (Water3D.cs, updateObject())

Die Textur mit der gespiegelten Landschaft bekommt man mit der Methode getEnvTexture(). Mit dem TextureCoordinateIndex bestimmt man, welche Koordinaten als Texturkoordinaten verwendet werden sollen. Im oberen Exkurs heißt es, dass die lokalen Vertex-Koordinaten durch Multiplikation mit View- und Projection-Matrix in den Clipping-Space transformiert werden. Dies ist relevant für ein Shader-Programm, das die untransformierten Vertex-Koordinaten empfängt. Im oberen Fall kann man jedoch die View-Koordinaten benutzen (CameraSpacePosition), wodurch die Multiplikation mit der View-Matrix wegfällt. Als nächstes legt man fest, dass dreidimensionale Texturkoordinaten verwendet werden sollen (TextureTransform.Count3) und die ersten beiden Komponenten durch die dritte geteilt werden (TextureTransform.Projected).

Die Remapping-Matrix (matTexScale) sieht aus, wie im Exkurs beschrieben und wird mit der Projektionsmatrix multipliziert. Die Anwendung der Texturtransformation geschieht mit der schon bekannten Methode SetTransform(…). Allerdings wird nun die Texturstufe, die transformiert werden soll angegeben und die Transformationsmatrix hierauf angewendet.

// set cube texturedevice.SetTexture(2, cubeTexture);device.SamplerState[2].MinFilter = TextureFilter.Linear;device.SamplerState[2].MagFilter = TextureFilter.Linear;device.SamplerState[2].MipFilter = TextureFilter.Linear;device.TextureState[2].TextureCoordinateIndex = (int)TextureCoordinateIndex.CameraSpaceReflectionVector;device.TextureState[2].TextureTransform = TextureTransform.Count3;device.TextureState[2].ColorOperation = TextureOperation.Lerp;device.TextureState[2].ColorArgument1 = TextureArgument.TextureColor;device.TextureState[2].ColorArgument2 = TextureArgument.Diffuse;device.SetTransform(TransformType.Texture2, matViewInv);drawIndexedPrimitives();device.RenderState.AlphaBlendEnable = false;device.TextureState[1].ColorOperation = TextureOperation.Disable;device.TextureState[2].ColorOperation = TextureOperation.Disable;}Listing 4.3.18 Cube-Mapping (Water3D.cs, updateObject())

Die dritte Texturstufe realisiert die globalen Reflexionen mit einer Cube-Map, welche die Texturen der Skybox enthält. Hier gibt man als TextureCoordinateIndex den CameraSpaceReflectionVector an. Dies ist der Reflexionsvektor, der sich aus der Vertex-Position und der Vertex-Normalen errechnet. In Verbindung mit dreidimensionalen Texturkoordinaten wird so der reflektierte Punkt der Umgebung in der Cube-Map indiziert. Da die globalen Reflexionen (annähernd) betrachterunabhängig sind, muss man die Koordinaten noch in den World-Space transformieren, was durch die Multiplikation mit der inversen View-Matrix geschieht. Nun können die Primitive gezeichnet, das Alpha-Blending ausgeschaltet und die zusätzlichen Texturstufen deaktiviert werden. Die Art der Überblendung (ColorOperation) wurde so gewählt, wie es am Besten aussieht. Die Möglichkeiten der Überblendung und Farbargumente kann man in der DirectX-Dokumentation [15] und Kapitel 2 nachlesen.

Die Landschaft

Auslesen einer Bitmap

Rendern in eine Textur

 

Die Landschaft wird mit Hilfe einer Heightmap erzeugt und mit einer einfachen Wüstentextur überzogen. Eine Heightmap ist eine 8-Bit Graustufen-Bitmap, bei der helle Stellen höhere und dunkle Stellen tiefere Punkte der Landschaft beschreiben. Da sich die Positionen der Vertices nicht verändern, kann man den Vertex-Buffer und den Index-Buffer im Konstruktor füllen. Dies geschieht mit der Methode drawLandscape(). Die Heightmap wird in einem zweidimensionalen Feld gespeichert. Die Werte dieses Feldes werden dann als y-Koordinate der Landschaft verwendet, wie es auch bei der Wasseroberfläche getan wird. Ansonsten verläuft das Rendern der Landschaft auch analog zur Wasseroberfläche, weshalb an dieser Stelle nur der Konstruktor gezeigt wird, in dem auch die Bitmap ausgelesen wird.

public Landscape(Device device, String fileName, Vector3 pos) : base(device, pos){this.device = device;baseTexture = TextureLoader.FromFile(device, Application.StartupPath + @“\..\..\Media\Textures\sahara_sand_patterns_220513.JPG“);heightFile = Bitmap.FromFile(Application.StartupPath + @“\..\..\Media\Heightmaps\“ + fileName) as Bitmap;quad_num = heightFile.Width;initIndexVertexBuffer();heightmap = new float[quad_num, quad_num];for (int j = 0; j < quad_num; j++){for (int i = 0; i < quad_num; i++){heightmap[i, j] = heightFile.GetPixel(i, j).GetBrightness()*36.0f;}}// draw in local spacedrawLandscape();// set to world spacesetObject(pos.X, pos.Y, pos.Z);}Listing 4.3.19 Auslesen einer Bitmap (Landscape.cs, Konstruktor)

Der Wert, der aus der Bitmap gelesen wird, ist zwischen 0.0 und 1.0, weshalb die Höhe noch durch einen Höhenfaktor von 36 skaliert wird. Je größer dieser ist, desto hügeliger wird das Gelände.

Rendern der Landschaft in eine Textur

Für das Rendern in eine Textur ist die Klassse TextureRenderer zuständig. Sie stellt eine 256×256 Pixel große, zweidimensionale Textur zur Verfügung, in die ein beliebiges 3D-Objekt gerendert werden kann. Die Textur ersetzt im Prinzip den ViewPort als Render-Target. Daher bietet die Klasse TextureRender die Methode renderEnvironmentToTexture(…) an, bei der man, neben dem zu zeichnenden Objekt und der Texturfarbe, auch die Transformationsmatrizen angibt. Diese können verschieden von den „normalen“ Matrizen sein, weil man auf ein anderes Render-Target zeichnet. Wie im obigen Exkurs beschrieben ist die View-Matrix in diesem Fall auch unterschiedlich, denn sie spiegelt die Landschaft. Weil der Inhalt der Textur betrachterabhängig ist, muss die Textur in der Render()-Methode gefüllt werden. Durch die ständige Aktualisierung des Inhalts der Textur würde auch die Spiegelung eines sich rotierenden Objektes rotieren.

if (renderEnv){clipProj = texRend.projMatrixForClipping(setWaterPlane());Matrix reflDir = Matrix.Identity;Matrix viewDir = camera.getViewMatrix();reflDir.M11 = 1.0f;reflDir.M22 = -1.0f;reflDir.M33 = 1.0f;reflDir.M24 = 0.0f;reflDir.M41 = 0.0f;reflDir.M42 = 2.0f * water.getPosition().Y; //2*height of //water planereflDir.M43 = 0.0f;reflDir.M44 = 1.0f;viewDir = Matrix.Multiply(reflDir, viewDir);texRend.renderEnvironmentToTexture(landscape, clipProj, viewDir, landscape.getWorldMatrix(), Color.LightSeaGreen);}Listing 4.3.20 Vorbereitung zum Rendern in eine Textur (RenderEngine.cs, Render())

Zunächst wird man mit einem Problem konfrontiert, wofür es viele Lösungswege gibt: Die Teile des Objekts, die sich unter Wasser befinden sind nach der Spiegelung über der Wasseroberfläche und werden mit in die Textur gerendert. Dies führt zu einem unakzeptablen Ergebnis. Daher muss der Teil unter Wasser vor dem rendern abgeschnitten werden. Dafür wird eine Methode verwendet, die von Eric Lengyel entwickelt wurde und Oblique-Frustum-Clipping heißt. Sie transformiert die Near-Plane des View-Frustums auf eine beliebige Oberfläche (in diesem Fall das Wasser), indem sie die Projektionsmatrix manipuliert. Die Technik funktioniert auf jeder 3D-Hardware und verbraucht keine zusätzlichen GPU-Ressourcen. Außerdem wird die Projektionsmatrix auch der Methode renderEnvironmentToTexture(…) mitgegeben, weshalb sie nur noch mit der Methode projMatrixForClipping(…) (die den Algorithmus von Eric Lengyel implementiert) erzeugt werden muss. Die Methode setWaterPlane() definiert die Ebene des Wassers im View-Space.

Andere Methoden zum benutzerdefinierten Clipping sind User-Clip-Planes (müssen von der Hardware unterstützt werden), Alpha-Testing (benutzt eine Texturstufe und den Alpha-Kanal) und Pixel-Shader basiertes Culling.

Mit der modifizierten Projektionsmatrix und der gespiegelten View-Matrix kann man nun die gewünschte Textur-Render-Methode aufrufen.

public Texture renderEnvironmentToTexture(Object3D obj, Matrix renderProj, Matrix renderView, Matrix renderWorld, Color backgrndC){Matrix saveProj = camera.getProjectionMatrix();Matrix saveView = camera.getViewMatrix();this.renderProj = renderProj;this.renderView = renderView;this.renderWorld = renderWorld;// get render target using (Surface oldRenderTarget = device.GetRenderTarget(0)){// get surface of textureusing (Surface s = texture.GetSurfaceLevel(0)){device.SetRenderTarget(0, s);// Clear the scenedevice.Clear(ClearFlags.Target | ClearFlags.ZBuffer, backgrndC, 1.0f, 0);device.BeginScene();obj.updateObject(renderWorld, renderView, renderProj);device.EndScene();}device.SetRenderTarget(0, oldRenderTarget);device.SetTransform(TransformType.Projection, saveProj);device.SetTransform(TransformType.View, saveView);}return texture;}Listing 4.3.21 Rendern in eine Textur (TextureRenderer.cs, renderEnvironmentToTexture(…))

Zunächst speichert man die ursprüngliche Projektions-und View-Matrix.

Eine Surface ist bei DirectX ein linearer Bereich von Bildschirmspeicher. Normalerweise befinden sich Surfaces im Speicher der Grafikkarte. Sie können jedoch auch im Arbeitsspeicher existieren und werden von der Surface-Klasse verwaltet. Ein RenderTarget ist ein Farbpuffer, in den Pixel-Daten der Szene geschrieben werden. Normalerweise ist dies eine Surface, die den Viewport repräsentiert. In diesem Fall wird es eine Textur sein.

Das alte Render-Target wird gespeichert (device.GetRenderTarget(0)) und durch die Surface-Repräsentation (s = texture.GetSurfaceLevel(0)) der Textur ersetzt (device.SetRenderTarget(0, s)). Nun kann man mit den Befehlen, die normalerweise in den Back-Buffer rendern, in die Textur zeichnen. Hierzu benötigt die Methode updateObject() eine Überladung, die sämtliche Transformationsmatrizen (World, View, Projection) durch die für das Spiegeln relevanten ersetzt. Wenn man nun diese überladene Methode mit den zuvor fesgelegten Matrizen aufruft, wird die Landschaft gespiegelt und geclippt in die Textur gerendert.

Danach werden die alten Matrizen sowie das Render-Target restauriert und die Textur zurückgegeben. Auf die Textur, die nun die gespiegelte Landschaft enthält, kann man mit der Methode getEnvironmentTexture() zugreifen.

Meshes

Ein Mesh ist eine Zusammenfassung von Vertices zu einem Objekt. DirectX hat ein eigenes Dateiformat für Modelldateien. Dies sind Beschreibungen von Objekten, inklusive Vertices, Texturen und Materialparameter, in einer .x-Datei. So kann man Objekte mit einem 3D-Modellierungsprogramm (z.B. 3D Studio Max, Maya) erstellen, in das DirectX-Dateiformat konvertieren (z.B. mit Milkshape 3D) und in das Programm integrieren. Die Klasse Mesh von WaveGen liest eine .x-Datei aus und zeichnet das Objekt (updateObject()). Die Methoden zum Bewegen und Rotieren eines Objektes sind noch nicht implementiert. Die Mesh-Dateien müssen sich im Verzeichnis Media\Meshes befinden, die dazugehörigen Texturen im Verzeichnis Media\Textures.

 

Die Kamera

Manipulieren der View-Matrix

Bewegen entlang der Kamera-Achsen

Rotieren um eine beliebige Achse

Die Kamera ist die Repräsentation des View-Space und manipuliert dementsprechend die View-Matrix. Die Kamera realisiert die Bewegung des Betrachters und muss sich deshalb in alle Richtungen bewegen und rotieren können. Außerdem gibt die Kamera die View- und Projektionsmatrix zurück. Die Abfrage dieser Matrizen, wie auch der World-Matrix jedes Objektes, kann nicht über das Device-Objekt erfolgen, wenn man ein Pure-Device benutzt (siehe DirectX-Initialisierung).

Neben dem üblichen Device bekommt der Konstruktor die Argumente, die auch die View-Matrix benötigt: die Position (vEye), den Zielpunkt (vDest) und den Up-Vektor (vUp). Daraus berechnet man den View-Vektor (Richtungsvektor der Kamera, vDest – vEye) und den Right-Vektor (Vektor, der Senkrecht auf dem Up-Vektor und dem View-Vektor steht und nach rechts zeigt). Nun bewegt man sich in Richtung dieser Achsen, indem man vDest und vEye entsprechend verschiebt. Wenn man vEye in Richtung Up-Vektor verschiebt, bewegt sich die Kamera nach oben, der Fokus bleibt aber auf vDest. Listing 4.4.1 zeigt die Implementierung der Kamerabewegung.

public void moveObject(float x, float y, float z){vView = getViewVector();vRight = getRightVector();vView.Normalize();vRight.Normalize();// move forward (z-direction)vEye.X += this.vView.X * z;vEye.Z += this.vView.Z * z;// move right (x-direction)vEye.X += this.vRight.X * x;vEye.Z += this.vRight.Z * x;vDest.X += this.vView.X * z;vDest.Z += this.vView.Z * z;vDest.X += this.vRight.X * x;vDest.Z += this.vRight.Z * x;// move up (y-direction), keep destinationvEye.X += this.vUp.X * y;vEye.Y += this.vUp.Y * y;}Listing 4.4.1 Bewegung der Kamera (Camera.cs, moveObject(…))


Die Rotation der Kamera um eine beliebige Achse wird folgendermaßen implementiert. Alternativ könnte man auch die DirectX-Methode Matrix.RotationYawPitchRoll(…) verwenden, welche eine Matrix erzeugt, die ein Objekt um die y-Achse (Yaw), die x-Achse (Pitch) und die z-Achse (Roll) rotiert.

/// <summary>/// rotate camera around arbitrary axis /// </summary>public void rotateObject(float angle, Vector3 axis){Vector3 vNewView;Vector3 vView;axis.Normalize();float x = axis.X;float y = axis.Y;float z = axis.Z;// Get our view vector (The direciton we are facing)vView = getViewVector();// Calculate the sine and cosine of the angle oncefloat cosTheta = (float)Math.Cos((double)angle);float sinTheta = (float)Math.Sin((double)angle);// Find the new x position for the new rotated pointvNewView.X = (cosTheta + (1 – cosTheta) * x * x) * vView.X;vNewView.X += ((1 – cosTheta) * x * y – z * sinTheta) * vView.Y;vNewView.X += ((1 – cosTheta) * x * z + y * sinTheta) * vView.Z;// Find the new y position for the new rotated pointvNewView.Y = ((1 – cosTheta) * x * y + z * sinTheta) * vView.X;vNewView.Y += (cosTheta + (1 – cosTheta) * y * y) * vView.Y;NewView.Y += ((1 – cosTheta) * y * z – x * sinTheta) * vView.Z;// Find the new z position for the new rotated pointvNewView.Z = ((1 – cosTheta) * x * z – y * sinTheta) * vView.X;vNewView.Z += ((1 – cosTheta) * y * z + x * sinTheta) * vView.Y;vNewView.Z += (cosTheta + (1 – cosTheta) * z * z) * vView.Z;// Now we just add the newly rotated vector to our position to set// our new rotated view of our camera.vDest.X = vEye.X + vNewView.X;vDest.Y = vEye.Y + vNewView.Y;vDest.Z = vEye.Z + vNewView.Z;}Listing 4.4.2 Rotation der Kamera (Camera.cs, rotateObject(…))

Mit den neuen Werten für vEye und vDest (vUp bleibt gleich) kann man dann eine neue View-Matrix erstellen und DirectX zuweisen.

public void updateObject(){viewMatrix = Matrix.LookAtLH(vEye, vDest, vUp);device.Transform.View = viewMatrix;}Listing 4.4.3 Anwendung der View-Transformationen (Camera.cs, updateObject(…))
Wie schon oben erwähnt muss die View-Matrix vorher separat gespeichert werden, da eine direkte Abfrage der View-Matrix über das Device meist nicht möglich ist.

 

Die Effekte

Einbinden und Rendern von Shader-Effekten

 

Das Einbinden der Shader-Effekte übernehmen die Klassen EffectContainer und ShaderEffect. Die Klasse EffectContainer liest die XML-Beschreibungen der Effekt-Dateien (.fx) aus und speichert so den Effekt-Zustand für ein bestimmtes 3D-Objekt.

Dazu gehört als Erstes der Dateipfad, der gebraucht wird, um das DirectX Effekt-Objekt zu erstellen.

xmlreader.ReadStartElement(„EffectFile“);effectfile = xmlreader.ReadString();// Load the effect from file.effect = Effect.FromFile(device, Application.StartupPath + @“\..\..\“ + effectfile, null, null,ShaderFlags.None, null);xmlreader.ReadEndElement();Listing 4.5.1 Laden eines Effektes (EffectContainer.cs, Konstruktor)

Die Variable effect stellt eine Referenz auf das Shader-Programm dar. Hiermit kann man alle Operationen ausführen, die für das Shader-Programm möglich sind, vor allem aber das Festlegen von Werten für Variablen im Shader-File. Welche Variablen von WaveGen einen Wert zugewiesen bekommen, definiert man in der Variablen-Sektion (<Variables>) der XML-Datei.

Zunächst beschreibt man die Variablen im Effekt-File und weist ihnen (Start-) Werte zu. Zugelassene Variablentypen sind einfache Texturen (<Texture>), Cube-Texturen (<CubeTexture>), Volumen-Texturen (<VolumeTexture>), Fließkommazahlen (<float>), Integers (<int>), Strings (<String>), Matrizen (<Matrix>), Vektoren (<Vector4>), Farbwerte (<ColorValue>) und boolsche Werte (<bool>). Eigentlich kann man dem Shader-Programm auch Felder dieser Datentypen übertragen sowie die Datentypen void* und GraphicsStream. Das ist jedoch über die XML-Beschreibung nicht möglich.

Die Variablen können sich ständig ändern (View-Matrix, Zeit …) oder gleich bleiben. Im ersten Fall definiert man sie in der Sektion Mutable und gibt die Methode mit, welche die Werte zurückgibt. Diese Methode muss sich auch in der Klasse EffectContainer befinden.

Werte, die sich im Wesentlichen nicht ändern, beschreibt man in der Sektion Uniform. Hier kann man entweder direkte Werte oder Methoden angeben. Variablen dieser Sektion können auch über ein Kommando auf der Konsole geändert werden. Diese Kommandos definiert man in der Sektion Commands. Die Variablennamen mit ihren Werten sowie die Kommandos mit der zu ändernden Variable werden dann jeweils in einer Hashtabelle gespeichert. Dort kann man die Variablenwerte bei Bedarf abrufen.

Die Variablenwerte benötigt die Klasse ShaderEffects. Sie kümmert sich um die Darstellung der Effekte und besitzt die Methode updateEffect(), die analog zur updateObject()-Methode der 3D-Objekte arbeitet. Daher muss sie sich in der Render()-Methode befinden und statt der updateObject()-Methode des entsprechenden Objektes aufgerufen werden.

public void updateEffect(){// Update the effect’s variables. Instead of using strings, it // would // be more efficient to cache a handle to the parameter by calling // Effect.GetParametercontainer.update();foreach (DictionaryEntry de in container.getMutableEffectParameters()){if(de.Value.GetType().BaseType == typeof(BaseTexture))effect.SetValue((String)de.Key,(BaseTexture)de.Value);else if(de.Value.GetType() == typeof(Matrix))effect.SetValue((String)de.Key, (Matrix)de.Value);else if(de.Value.GetType() == typeof(float))effect.SetValue((String)de.Key, (float)de.Value);else if(de.Value.GetType() == typeof(Vector4))effect.SetValue((String)de.Key, (Vector4)de.Value);else if (de.Value.GetType() == typeof(int))effect.SetValue((String)de.Key, (int)de.Value);else if (de.Value.GetType() == typeof(bool))effect.SetValue((String)de.Key, (bool)de.Value);else if (de.Value.GetType() == typeof(String))effect.SetValue((String)de.Key, (String)de.Value);else if (de.Value.GetType() == typeof(ColorValue))effect.SetValue((String)de.Key, (ColorValue)de.Value);}effect.Technique = container.getTechnique();// Effect.Begin returns the number of// passes required to render the effect.int passes = effect.Begin(0);// Loop through all of the effect’s passes.for (int i = 0; i < passes; i++){// Set state for the current effect pass.effect.BeginPass(i);// Render object’s primitives.container.getObject3D().drawIndexedPrimitives();//obj.updateObject();// End the effect passeffect.EndPass();}// Must call Effect.End to signal the end of the technique.effect.End();}Listing 4.5.2 Zeichnen des Objektes mit Shader-Effekt (ShaderEffect.cs, updateEffect())

In der Methode update() werden die Methoden aufgerufen, die den Wert der veränderlichen Variablen zurückgeben. Dann wird die Hashtabelle mit den veränderlichen Variablen ausgelesen, die Werte in den entsprechenden Datentyp konvertiert und mit SetValue(…) an das Shader-Programm übergeben. Die unveränderlichen Variablen werden im Konstruktor festgelegt.

In jedem HLSL-Programm definiert man eine Technik (Technique). Sie umkapselt den Effekt-Zustand, legt den Render-Stil fest und besteht aus einem oder mehreren Durchgängen (passes). Der Name der Technik wird auch im XML-File beschrieben und in der Klasse ShaderEffects angewendet. Dann ermittelt man die Anzahl der Durchgänge, die im Effekt-File stehen. Meistens genügt ein Durchgang. Mehrere Durchgänge benötigt man für aufwendige Effekte. In einer For-Schleife über die Anzahl der Durchgänge führt man dann die Render-Funktion des Effektes aus. Diese beginnt mit BeginPass(…) und endet mit EndPass(). Dort werden die Primitive des Objektes gezeichnet. Nach der For-Schleife signalisiert man mit End(), dass die Technik beendet ist.

Kompilieren von Programmcode zur Laufzeit

Reflection-API

CodeDOM

Delegates

Nachdem die Grundlagen der 3D-Programmierung anhand des Programms WaveGen erklärt wurden, werden nun noch einige Besonderheiten des .NET-Frameworks erläutert. Dies ist zum Einen eine Programmierschnittstelle zur statischen Analyse von Programmen. Diese heißt Reflection-API. Mit ihrer Hilfe lassen sich Metainformationen eines Programms zur Laufzeit ermitteln. Die Reflection-API wird z.B. genutzt, wenn anhand eines Strings in der XML-Datei die dazugehörige Methode ermittelt und ausgeführt wird. Listing 4.6.1 demonstriert das Einfügen der Variablenwerte in eine Hashtabelle (Add(…)) und das Aufrufen einer Methode anhand des <method>-Strings aus der XML-Datei mithilfe der Reflection-API (InvokeMember(…)).

foreach (DictionaryEntry de in mutableHelp){mutable.Add(de.Key, this.GetType().InvokeMember((String)de.Value, BindingFlags.Public | BindingFlags.Instance | BindingFlags.InvokeMethod, null, this, null));}Listing 4.6.1 Aufrufen einer Methode über einen String (EffectContainer.cs, Konstruktor)

Die andere, hilfreiche Bibliothek ist der CodeDom. Der Namensraum System.CodeDom bietet Methoden zur einfachen Generierung und Kompilierung von Quellcode zur Laufzeit.

Eine Anwendung dieser beiden Hilfsmittel befindet sich in der Klasse FunctionCompiler. Hier werden die Funktionen, die an der Konsole eingegeben werden, und der eigene Code im Programmierfenster dynamisch kompiliert.

Die Methode compile() ist das Herzstück der Klasse. Sie interpretiert jeden Befehl, der auf der Konsole eingegeben wird und versucht, die Eingabe zu kompilieren, falls die Interpretation nicht zum Erfolg führt.

// code for function and source compiling//funTable cant contain „“ or „0.0“ so this is allowedif (!funTable.ContainsKey(funDef) && !funTable.ContainsKey(sourceCode)){// Template code including dummies for function and code // stringString source = „using System; using OwnFunctions; namespace Water3D {public class CompiledExpression {public static double f(double x, double z, double t) {###s###; return ###f###;}}}“;// replace dummy with functionsource = source.Replace(„###s###“, sourceCode);source = source.Replace(„###f###“, funDef);// generate c# code provider to create the compilerCSharpCodeProvider provider = new CSharpCodeProvider();// set compiler parametersCompilerParameters cps = new CompilerParameters();cps.GenerateInMemory = true;cps.ReferencedAssemblies.Add(„System.dll“);cps.ReferencedAssemblies.Add(„OwnFunctions.dll“);//compile codeCompilerResults cr = provider.CompileAssemblyFromSource(cps, source);//look for compile errorsif (cr.Errors.Count > 0){String text = „Compile Errors:\n“;for (int i = 0; i < cr.Errors.Count; i++)text += cr.Errors[i].ErrorText + „\n“;statusWindow.loadString(text);statusWindow.showStatusWindow();f = null;}else{Type cet = cr.CompiledAssembly.GetType(„Water3D.CompiledExpression“);f = (fDelegate)System.Delegate.CreateDelegate(typeof(fDelegate), cet.GetMethod(„f“, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static));Listing 4.6.2 Dynamisches Kompilieren (FunctionCompiler.cs, compile())

Zunächst definiert man einen Template-String, der den Klassenrahmen der zu kompilierenden Funktion enthält. Die Klasse heißt CompiledExpression und bindet den Namensraum OwnFunctions ein, in dem eigene Methoden definiert sind. In dem Template-String befinden sich Dummy-Strings, die mit dem eingegebenen Code ersetzt werden. Wenn eine Funktion eingegeben wurde, ist der Code-String leer. Wenn Quellcode eingegeben wurde, gibt die Funktion 0.0 zurück. Dies ist auch sinnvoll, da der Quellcode nur wirksam wird, wenn er etwas zurückgibt. Ist dies nicht der Fall, wird wenigstens 0.0 zurückgegeben und das Wasser bleibt still. Durch das dynamische Kompilieren ist es möglich, beliebige Funktionen an der Konsole einzugegeben. So ist das Erzeugen von Wellen sehr flexibel.

Nachdem die Dummies durch Quellcode ersetzt sind, erstellt man einen CSharpCodeProvider, welcher den Compiler repräsentiert. Nachdem durch die CompilerParameters bestimmt wird, dass die kompilierte Assembly nur im Arbeitsspeicher generiert werden soll (nicht als ausführbare Datei gespeichert wird) und die zu ladenden, dynamischen Bibliotheken festgelegt sind, kann man den Quellcode kompilieren. Die resultierenden CompilerResults repräsentieren dann die Assembly.

Nun benötigt man noch eine Repräsentation der kompilierten Funktion im Programm. Diese bekommt man durch die Delegate-Typen des .NET-Frameworks. Dies sind typisierte Funktionszeiger.

/// <summary>/// definition of delegate (representation of compiled function)/// </summary>public delegate double fDelegate(double x, double z, double t);Listing 4.6.3 Definition eines Delegates (FunctionCompiler.cs)

In diesem Fall erzeugt man durch die Methode CreateDelegate(…) einen Funktionszeiger auf die eigentliche, gerade kompilierte Methode f und nennt dieses Delegate ebenfalls f. Die Methode CreateDelegate(…) benötigt als erstes Argument den zu erzeugenden Typ, der über GetType(…) erfahren werden kann. Als zweites Argument benötigt sie das MethodInfo-Objekt der Reflection-API, welches die Methode f der CompiledExpression-Klasse beschreibt.

Wenn man im Programm die Methode f aufruft, wird so in Wirklichkeit die Methode der entsprechend kompilierten Funktion aufgerufen. Wichtig ist hierbei, dass die Signaturen der Methoden übereinstimmen. Die Delegates aller schon einmal kompilierten Methoden werden in einer Hashtabelle (funTable) gespeichert. So kann man auf alle Funktionen, die schon einmal eingegeben und kompiliert wurden über ihren Schlüssel (String-Repräsentation der Funktion) zugreifen. Dies spart etwas Speicherplatz, weil einmal kompilierte Assemblies nicht mehr aus dem Speicher entfernt werden. Die Methode plotFunction(…) wird von der Klasse Water3D aufgerufen, um die y-Positionen der Vertices zu bekommen. Sie ruft die aktiven, kompilierten Funktionen auf, die in der Liste funActive gespeichert sind. Bei WaveGen werden alle aktiven Funktionen addiert, was einer Überlagerung der Wellen entspricht.

public double plotFunction(double x, double z, double t){double sum = 0.0;foreach (Object o in funActive){sum += ((fDelegate)o)(x, z, t);}return sum;}Listing 4.6.4 Ausführen der Funktionen über Delegates (FunctionCompiler.cs, plotFunction())

Lizenz

Managed DirectX Copyright © Andreas Zimmer. Alle Rechte vorbehalten.

Dieses Buch teilen