Dieser Artikel erschien erstmals im PC Magazin 12/2001. Die Wieder­veröffentlichung erfolgt mit freundlicher Genehmigung der WEKA Media Publishing GmbH.

Physikalische Simulation von Wasserwellen

Alles fließt

Nutzen Sie Forschungs­ergebnisse aus der Ozeanographie, um Wasserwellen physikalisch zu simulieren. Sie animieren realistische Wellen.

Carsten Dachsbacher

Um 3D-Welten in Spielen darzustellen, genügt es nicht mehr, die 3D-Szenen technisch und künstlerisch ansprechend zu rendern. In den letzen Jahren begannen Programmierer deshalb physikalische Vorgänge erfolgreich zu simulieren. Meist beschränkte sich die Simulation in Computer­spielen darauf, starre Körper zu bewegen und Kollisionen zu berechnen. Der zweite große Trend zeigte dann, wie sich große Outdoor-Spielewelten mit eindrucks­vollen Landschaften und blühender Flora und Fauna rendern ließen.

Wir widmen uns in dieser Ausgabe der physikalischen Simulation von Wasserwellen, in tiefem Wasser, wie Sie es vom Meer her kennen. Dieses Gebiet wurde von pro­fessionellen Forschern genau untersucht. Diese Experten sind nicht als Programmierer oder Grafiker ausgebildet. Die Profis im Dienst der Marine berechnen, wie sich Schiffe auf offener und schwerer See bewähren müssen.

Wellen aus Sinusschwingungen

Wenn Sie über die Simulation von Wellen nachdenken, mag Ihnen ein Polygongitter in den Sinn kommen. Bei diesem verschieben Sie die Position der Vertizes, womit der richtige Anfang gemacht ist. Im nächsten Schritt perfek­tionieren Sie die Bewegung dadurch, dass Sie die Verschiebung nach oben und unten durch überlagerte Sinus­schwingungen steuern und berechnen. Damit vermitteln Sie einen Eindruck, dass es sich um Wasser handelt,.

Die beiden Forscher Pierson und Moskowitz haben in ihren Untersuchungen im Jahr 1964 festgestellt, dass sich die Bewegung der Wasser­oberfläche durch eine Überlagerung der richtigen Sinus­schwingungen darstellen lässt. Dazu haben sie eine ganze Reihe von Windseen untersucht.

Die Pierson-Moskowitz-Formel

DAS PIERSON-MOSKOWITZ-SPEKTRUM für U(10) = 5 m/s
DAS PIERSON-MOSKOWITZ-SPEKTRUM für U(10) = 5 m/s

Das Ergebnis ihrer Arbeit war die sogenannte Pierson-Moskowitz-Formel (kurz PM-Formel). Mit dieser Formel können Sie für eine konstante Windge­schwindigkeit die spektrale Energie­verteilung eines voll entwickelten Windsees berechnen. Vollentwickelt bedeutet, Sie berechnen die Wellen für einen Zeitpunkt, zu dem der Wind schon so lange auf das Wasser gewirkt hat, dass sich eine Art Gleichgewicht gebildet hat zwischen der Interaktion Wind-Wasser.

Die spektrale Energie­verteilung besagt, wie stark welche Sinusfrequenz zu der Wasser­oberfläche beiträgt (ihre Amplitude). Die PM-Formel berechnet die Wellen auf der Grundlage des Windes. Wind ist auch der Haupt­verursacher an Wellen auf dem Meer. Der Wind verursacht auf eine Wasser­oberfläche Wellen, weil sich durch kleine Turbulenzen der Luftdruck über der Wasser­oberfläche ändert und somit auf sie wirkt.

Die PM-Formel für die Peakfrequenz (die stärkste Sinus­schwingung im Spektrum) und die ein­dimensionale Energie­verteilung, die sich aus den Versuchen ergab, sieht wie folgt aus: PM-Formel

Im Bild sehen Sie das mit der Formel berechnete Spektrum für die Windge­schwindigkeit, die in 10 Meter Höhe gemessen wird, mit einem Wert von:

U(10) = 5m/s

Mit der Dispersions­gleichung für Gravitations­wellen in tiefem Wasser können Sie die Wellenlängen, bzw. den Wellenlängen­bereich mit der meisten Energie, berechnen. Die Konstante g ist die Erdbe­schleunigung und k=2*pi/lambda die Wellennummer: Dispersionsgleichung

Eine weitere Formel ist das Jonswap-Spektrum: Jonswap-Spektrum

Für das Jonswap Modell gibt es noch eine spezielle Funktion, mit der Sie die Peakfrequenz berechnen können: Peakfrequenz

Der Unterschied zwischen den verschiedenen Formeln für die Energie­verteilung liegt hauptsächlich in der Ausprägung des Maximums. Alle diese Formeln basieren auf Beobachtungen. Deshalb gibt es unterschied­liche Formeln für einen Vorgang. Allgemein gilt: Wenn die Windge­schwindigkeit zunimmt, wird die Peakfrequenz kleiner und die Wellenlänge größer. Wer dies beobachten konnte, wird sich dieser Faustregel anschließen. Umgekehrt ist einsichtig, dass bei Windstille keine Wellen auftreten.

Die Richtung der Sinuswellen

DIE ZWEIDIMENSIONALE Energieverteilung
DIE ZWEIDIMENSIONALE Energie­verteilung

Mit den Formeln, die wir Ihnen bisher vorgestellt haben, können Sie die Amplitude der Sinus­schwingungen für eine Wasser­oberfläche in Abhängigkeit von der Windstärke berechnen. Für die Darstellung der Wasser­oberfläche reicht diese Information nicht aus. Sie benötigen noch das Wissen über die Richtung, in der eine Sinuswelle läuft, also die Richtungs­verteilung. Diese ist natürlich abhängig von der Windrichtung. Diese Verteilung können Sie mit der Formel von Mitsuyasu bestimmen. Mitsuyasu-Formel

Diese berechnet die Energie, die eine Welle der Frequenz f hat, die im Winkel Theta zur Windrichtung läuft: Mitsuyasu-Formel

Für die Formel benötigen Sie folgende Fallunter­scheidungen: Fallunterscheidungen

Die Gamma-Funktion ist wie folgt definiert:

Aus den genannten Formeln und der PM-Formel (oder einer anderen Frequenz-Energie-Verteilungs­funktion) erhalten Sie dann die zwei­dimensionale Energiever­teilungs­funktion: Energieverteilungsfunktion

In Bild sehen Sie eine dieser Energie­verteilungen als 3D-Diagramm.

Die Berechnung der Amplitude

Aus der zwei­dimensionalen Energie­verteilungs­funktion können Sie die Amplitude berechnen. Dazu müssen Sie folgende Berechnungen durchführen: Amplitude

Die Ableitung von dk/df (sprich dknach df) berechnen Sie aus der Dispersions­gleichung: Dispersionsgleichung

Jetzt lösen Sie die obige Gleichung nach F(k) auf Dispersionsgleichung und setzen Sie in Dispersionsgleichung ein. Bei dem Omega Term handelt es sich um einen Korrekturterm, dessen mathematische Notwendigkeit für unsere Anwendung nicht konkret berechnet wird. Durch weitere Vereinfachung und Auflösung, erhalten Sie für die Amplitude folgende Formel Amplitude

Nun haben Sie die endgültige Formel, die Ihnen die Amplitude zu einer Sinus­schwingung auf der Wasser­oberfläche berechnet.

Vereinfachung für Echtzeit 3D-Grafik

Das berechnete Spektrum und die Energie­verteilung sind kontinuierlich. Das bedeutet, dass die Ergebnisse die Energie­verteilung für unendlich viele überlagerte Sinuswellen jeglicher Frequenz und Richtung darstellen. Auch wenn der Bereich der Funktionen eingeschränkt ist, in denen sie bedeutend größer als Null sind (sichtbarer Beitrag zum gerenderten Bild), sind im entsprechenden Bereich immer noch unendlich viele Frequenzen und Richtungen enthalten.

Verwenden Sie ein Polygongitter, dessen Vertizes Sie verschieben, um die Wellenbewegung darzustellen. Die Anzahl der Sinuswellen, die Sie in der Berechung überlagern, wird aber deutlich durch die Größe des Gitters und der verfügbaren Rechenzeit eingeschränkt. Suchen Sie sich in der Vorbe­rechnungsphase eine zufällige Frequenz und eine zufällige Richtung der Welle aus. Mit Hilfe der obigen Formeln können Sie die Amplitude dieser Welle berechnen. Wenn die Amplitude einen – von Ihnen gewählten – Grenzwert nicht überschreitet, also zu schwach ist, um im Bild sichtbar zu sein, verwerfen Sie die Zufallswerte und generieren eine neue Welle. Sie speichern also nur die Sinuswellen, die stark genug sind. Für den visuellen Eindruck genügen etwa 16 bis 32 dieser ausgewählten Wellen.

Die Implementation

Nachdem Sie die Windge­schwindigkeit und Windrichtung festgelegt haben, berechnen Sie zunächst die Peakfrequenz:


const float windSpeed = 5.5f;
const float windDirection = 0.0f;
const float gravity = 9.81f;
float freqPeak = 0.13f * gravity / windSpeed;
		

Zur Berechnung der Amplitude verwenden Sie folgende Funktionen für die Richtungs- und frequenz­abhängige-Energie­verteilung:


float directionEnergy(float f, float theta)
{
	float temp = cos(theta * 0.5f);
	temp *= temp;
	float s_sm, sm;
	if(f >= freqPeak)
	{
		s_sm = pow(f / freqPeak, -2.5f);
		sm = 9.77f;
	} else
	{
		s_sm = pow(f / freqPeak, 5.0f);
		sm = 6.97f;
	}

	float s = s_sm * sm;
	return gamma(s+1) * pow(temp, s) /
		(2.0 * sqrt(M_PI) * gamma(s + 0.5));
}

float energy1DFreq(float f)
{
	return alpha * gravity2 /
		(pow(2.0f * M_PI, 4) * pow(f, 5)) *
		exp(-5.0 / 4.0 * pow((f / freqPeak), 4.0));
}

float energy2DFreqAngle(float f, float theta)
{
	return energy1DFreq(f) *
		directionEnergy(f, theta);
}
		

Mit den gerade beschriebenen Funktionen können Sie die Amplitude einer Welle wie folgt berechnen:


float waveAmplitude(float f, float theta, float k)
{
	float omega = 10.0f;
	return sqrt(energy2DFreqAngle(f, theta) *
		gravity * M_PI * M_PI / (k * f * omega));
}
		

Als nächstes erzeugen Sie die einzelnen Wellen und berechnen deren Parameter. Die Parameter speichern Sie für jede Welle in einer Struktur:


typedef struct
{
	// Richtung (Winkel)
	float direction;
	// Richtung (Vektor)
	float dirX, dirY;
	// Wellenlänge
	float lambda;
	// Wellennummer
	float k;
	// Winkelgeschwindigkeit
	float omega;
	// Frequenz
	float freq;
	// Periodenlänge
	float periode;
	// Amplitude
	float amplitude;
	// Phase
	float phase;
} WAVE;
int nWaves;
WAVE wave[MAX_WAVES];
		

Die Berechnung und Selektion der Wellen erfolgen in einer While-Schleife:


WAVE *w = &wave[0];
nWaves = 0;
while(nWaves < 32)
{
	// zufällige Frequenz
	float frequency = freqPeak +
		rand() / 32768.0f - 0.5f;
		

Durch den Zufalls­zahlen­generator können auch negative Frequenzen auftauchen, die wir gleich an dieser Stelle ausschließen wollen:


if (frequency > 0.0f)
{
	w->lambda = 2.0f * M_PI / (pow(
		2.0f * M_PI * frequency, 2.0f) /
		gravity);
	w->k = 2.0f * M_PI / w->lambda;
	w->omega = sqrt(gravity * w->k);
	w->freq = w->omega / (2.0f * M_PI);
	w->periode = 1.0f / w->freq;
		

Aus der Richtung als Winkel ergibt sich der Vektor


w->direction = 0.0f + (rand() /
	16384.0f + 0.5f) * M_PI;
w->dirX = cos(w->direction) * 0.5f;
w->dirY = sin(w->direction) * 0.5f;
		

Die Startphase der Welle bestimmen Sie auch durch einen Zufallswert


float phi0 = rand() / 16384.0f * M_PI;
float a0 = waveAmplitude(w->freq,
	w->direction - windDirection, w->k);
	w->amplitude = a0 * cos(phi0);
	w->phase = a0 * sin(phi0);

Die Welle ist nur interessant, wenn Ihre berechnete Amplitude groß genug ist:


if (fabs(w->amplitude) >= 0.0001f)
{
	w++;
	nWaves++;
}
		

Zeitliche Animation der Wellen

Jetzt haben Sie alle Vorbereitungen getroffen, um mit der eigentlichen Animation des Wassers zu beginnen. Die Implementation des Renderings, dass Sie auf der Heft CD oder im Internet finden, verwendet den schon mehrmals eingesetzten PC Underground OpenGL Startup. Im Beispiel­programm wurde auf Geschwindigkeit optimiertes Rendering zu Gunsten der Übersicht­lichkeit verzichtet.

Um die Wasserfläche zu rendern, benötigen Sie ein zwei­dimensionales Array, in dem Sie die Höhen­verschiebung des entsprechenden Gitterpunktes (Vertex) speichern. Für die realistische Beleuchtung verwenden Sie außerdem eine Oberflächen­normale für jeden Vertex:


#define WATERX 128
#define WATERY 128
typedef struct
{
	float x, y, z;
} VERTEX3D;

float *waterHeight;
VERTEX3D *waterNormal;

waterHeight = new float[WATERX * WATERY];
waterNormal = new VERTEX3D[WATERX * WATERY];
		

Berechnen Sie während der Animation für einen Zeitpunkt time die aktuellen Gitter­verschiebungen für jeden Punkt des Gitters:


for (j = 0; j < WATERY; j++)
	for (i = 0; i < WATERX; i++)
	{
		float h = 0.0f;
		

Sie überlagern alle Wellen


for (k = 0; k < nWaves; k++)
{
	wave *w = &wave[k];
		

Entscheidend ist zum einen die Amplitude der Welle. Die Phase der Sinuswelle setzt sich aus der initialen Phase und der zeit- und ortsabhängigen Phasen­verschiebung zusammen:


	h += w->amplitude *
		sin(
		// Initiale Phase
		w->phase +
		// Ausbreitungsgeschwindigkeit
		w->omega*w->freq * (
			// Zeitliche Verschiebung
			time +
			// Ortsabhängige Verschiebung
			w->dirX * i +
			w->dirY * j)
		);
	}
}

waterHeight[i + j * WATERX] = h;
		

Für die Darstellung müssen Sie noch die Oberflächen­normalen berechnen. Es genügt, diese in einer Näherung zu berechnen. Alles andere wäre viel zu rechenintensiv für eine Echtzeit­anwendung.

Die Normale eines Punktes berechnen Sie durch die Höhendifferenz seiner Nachbarpunkte. Die X-Komponente der Normalen erhalten Sie durch die horizontalen Nachbarn im waterHeight-Array, die Z-Komponente aus den vertikalen. Die Y-Komponente, die nach oben zeigt, setzen Sie auf einen konstanten Wert:


for (j = 0; j < WATERY; j++)
	for (int i = 0; i < WATERX; i++)
	{
		float x, y, z, l;
		x = waterHeight[( i + 1) +
			j * WATERX] -
			waterHeight[(i - 1) +
			j * WATERX];

		z = waterHeight[i +
			( j + 1) * WATERX] -
			waterHeight[i + (j - 1) *
			WATERX];

		y = 8.0f;

		waterNormal[i + j * WATERX].x = x;
		waterNormal[i + j * WATERX].y = y;
		waterNormal[i + j * WATERX].z = z;
	}
		
DIE WASSERTEXTUR
DIE WASSERTEXTUR

Die Normalen brauchen Sie nicht normalisieren. OpenGL übernimmt das für Sie beim Rendering, wenn Sie glEnable( GL_NORMALIZE) nutzen.

Mit der Höhen­information und den Normalen für die Vertizes können Sie nun die Wasser­oberfläche rendern. Diese rendern Sie am besten mit der Grundfarbe Ihres Wasser, also einem Blauton und einer Textur, die Struktur von Wasserwellen abbildet.

DIE WASSEROBERFLÄCHE mit einfacher Beleuchtung bei leichtem Wind
DIE WASSER­OBERFLÄCHE mit einfacher Beleuchtung bei leichtem Wind
DIESELBE WASSEROBERFLÄCHE nur bei mehr Wind
DIESELBE WASSER­OBERFLÄCHE nur bei mehr Wind

Außerdem schalten Sie die OpenGL Beleuchtungs­berechnung ein. Wenn Sie Specular Highlights (Spiegelnde Reflexion) anschalten, können Sie den Eindruck vermitteln, als ob die Sonne sich auf dem Wasser spiegelt. Damit erhalten Sie Resultate wie in den nächsten Bildern mit leichtem Wind und etwas stärkerem Seegang. Der Himmel in den Screenshots wurde einfach mit einer Hinter­grundbitmap erzeugt. So zeichnen Sie die Wasser­oberfläche:


glColor3ub(120, 200, 255);
glDepthFunc(GL_LEQUAL);
// Texturwählen
bump->select();
for (int j = 0; j < WATERY; j++)
{
	glBegin(GL_QUAD_STRIP);
	for (int i = 0; i < WATERX; i++)
	{
		int x = i&(WATERX - 1);
		int y = j&(WATERY - 1);
		int y1 = (j + 1)&(WATERY - 1);
		glNormal3fv(&waterNormal[x + y * WATERX]);
		glTexCoord2f(i * 0.3f,j * 0.3f);
		glVertex3f((i - WATERX / 2),
			waterHeight[x + y * WATERX],
			(j - WATERY / 2));
		glNormal3fv(&waterNormal [x + y1 * WATERX]);
		glTexCoord2f(i * 0.3f, (j + 1) * 0.3f);
		glVertex3f((i - WATERX / 2),
			waterHeight[x + y1 * WATERX],
			(j + 1 - WATERY / 2));
	}
	glEnd();
}
		

Sie verbessern die Optik mit einer zusätzlichen Reflectionmap. Diese spiegelt den Himmel auf der Wasser­oberfläche.

DIE WASSEROBERFLÄCHE mit einer Reflectionmap
DIE WASSER­OBERFLÄCHE mit einer Reflectionmap

OpenGL berechnet für Sie die Textur­koordinaten für die Reflectionmap aus den Vertex­koordinaten oder den Normalen des Gitters. Die dafür nötigen Befehle sind glTexGeni(...) und glTexGenf(...). Damit können Sie die automatische Textur­generierung steuern.

Die Darstellung des Wassers lässt sich optimieren. Im Beispiel­programm sehen Sie mit einge­schaltetem Wireframe Rendering, das die weiter entfernten Polygone sehr klein werden. Das Gitter benötigt eine so hohe Auflösung, da das Wasser sonst von nahem zu grob wirkt. Rechnen Sie die Höhe eines beliebigen Punktes aus: Sie können Polygon­strukturen verwenden, die eine bessere Auflösungs­verteilung haben. Weitere Informationen finden Sie unter unserer Underground-Homepage www.dachsbacher.de/pcu.