Kollision von Kugeln

1. Aufgabe

Zahlreiche Vorgänge aus unserem täglichen Umfeld unterliegen den Gesetzen der Natur. Wir betrachten in diesem Kapitel eine praktische Anwendung aus einer Teildisziplin der Physik, der Mechanik. Sowohl beim Zusammenstoß zweier Autos auf einer Kreuzung wie auch beim Aufeinanderprallen zweier Kugeln beim Billardspielen sind die so genannten „Stoßprozesse“ im Spiel, siehe Abbildung Abbildung 1:

Der Zusammenstoß zweier Kugeln.

Abbildung 1. Der Zusammenstoß zweier Kugeln.


In Abbildung 1 erkennen wir den Zusammenstoß zweier Kugeln, die zunächst die Geschwindigkeitsvektoren v1 und v2 besitzen und sich nach dem Zusammenprall mit veränderten Geschwindigkeiten v1’ und v2’ weiterbewegen. In der nun Folgenden Aufgabe entwerfen wir ein Programm, dass den Zusammenstoß einer beliebigen Anzahl von Kugeln in einer begrenzten rechteckigen Fläche auf der Grundlage der mechanischen Stoßgesetze simuliert, siehe Abbildung Abbildung 2:

WPF-Anwendung mit kollidierenden Kugeln.

Abbildung 2. WPF-Anwendung mit kollidierenden Kugeln.


Zunächst müssen wir uns noch etwas der grauen Theorie zuwenden, das Thema lautet „Der elastische Stoß“. Unter einem Stoß versteht man die einmalige kurzzeitige Berührung zweier Körper unter Änderung des jeweiligen Bewegungszustandes. Bei der Beschreibung von Stoßvorgängen unterscheidet man auf der einen Seite zwischen einem elastischen und einem unelastischen Stoß und auf der anderen Seite zwischen einem zentralen und einem nicht zentralen Stoß. Bei einem nicht elastischen Stoß tritt eine Verformung des oder der beteiligten Körper ein, die Bewegungsenergie der Körper hat eine Deformationsarbeit verrichtet. Wir behandeln im folgenden ausschließlich elastische Stöße. Bei einem zentralen Stoß geht die Wirkungslinie der Kraft durch die Schwerpunkte der beiden Körper, sprich der Geschwindigkeitsvektor v vor dem Stoß und v nach dem Stoß liegt auf derselben Geraden, siehe Abbildung 3:

Der zentrale Stoß.

Abbildung 3. Der zentrale Stoß.


Beim zentralen Stoß liegen die Geschwindigkeitsvektoren der beiden beteiligten Körper vor und nach dem Stoß auf derselben Geraden. Für eine Simulation bewegter Kugeln benötigen wir zu einem bestimmten Zeitpunkt pro Kugel die Koordinaten ihres Mittelpunktes sowie einen Geschwindigkeitsvektor, der die Positionsänderung des Kugelmittelpunktes in einem bestimmten Zeitintervall beschreibt. Prallen zwei Kugeln aufeinander, so müssen wir für beide Kugeln ihre neuen Geschwindigkeitsvektoren berechnen, es kommen der Energieerhaltungs- und der Impulserhaltungssatz zur Anwendung. Der Einfachheit halber legen wir die Annahme zu Grunde, dass alle Kugeln dieselbe Masse besitzen. Wir beginnen die theoretischen Ausführungen zunächst mit dem geraden elastischen Stoß und bringen das Ergebnis dann bei der Betrachtung des schiefen elastischen Stoßes ein.

1.1 Der gerade elastische Stoß

Unter dem Impuls p eines Körpers mit der Masse m, der sich mit der Geschwindigkeit v bewegt, versteht man die Vektorgröße p = mv in Richtung des Geschwindigkeitsvektors v. Der Impulserhaltungssatz besagt, dass in einem abgeschlossenen System (d.h. es sind keine äußeren Kräfte wirksam) der Gesamtimpuls konstant ist. Für zwei Kugeln mit den Impulsen p1 = m1v1 bzw. p2 = m2v2 vor dem Zusammenstoß und p1 = m1v1 bzw. p2 = m2v2 gilt damit für die Summen der Vektoren

p1 + p2 = p1 + p2, also

m1v1 + m2v2 = m1v1 + m2v2

Da wir m1 = m2 voraussetzen, reduziert sich die letzte Gleichung auf

v1 + v2 = v1 + v2

bzw. etwas anders umgeformt

v1 - v1 = v2 - v2

Bei einem vollkommen elastischen Stoß gilt auch der Erhaltungssatz für die Summen der beteiligten Bewegungsenergien: Um einen Körper der Masse m aus dem Ruhezustand auf die Geschwindigkeit v zu beschleunigen, braucht man die Bewegungsenergie Ek = ½mv2 (auch als kinetische Energie bezeichnet). Für zwei Kugeln gilt nun folgender Energieerhaltungssatz:

In einem abgeschlossenen reibungsfreien System ist die Summe der kinetischen Energien stets konstant, d.h. es treten keine Verluste durch Verformung oder Reibung auf. Legen wir für zwei Kugeln vor und nach dem Stoß die beiden kinetischen Energien Ek1 = ½m1v12 und Ek2 = ½m2v22 bzw. Ek1 = ½m1v1’2 und Ek2 = ½m2v2’2 zu Grunde, so gilt

Ek1 + Ek2 = Ek1 + Ek2, also

½m1v12 + ½m2v22 = ½m1v1’2 + ½m2v2’2

Unter Berücksichtigung von m1 = m2 vereinfacht sich diese Gleichung zu

v12 + v22 = v1’2 + v2’2

Um die dritte Binomische Formel anwenden zu können, werden alle Glieder mit v1 auf die eine Seite und mit v2 auf die andere Seite gebracht. Man erhält dann

v12 - v1’2 = v2’2 - v22

Daraus folgt

(v1 - v1’)(v1 + v1’) = (v2’ - v2)(v2’ + v2)

Berücksichtigt man v1 - v1 = v2 - v2, so erhält man

v1 + v1’ = v2’ + v2

Addiert man nun die Gleichung v1 - v1 = v2 - v2 mit der letzten Gleichung, so ergibt sich 2v1 = 2v2’, also

v2’ = v1

Setzt man v2’ in die weiter oben ermittelte Gleichung v1 + v2 = v1 + v2 ein, so erhält man v1’ = v1 + v2 - v2’ = v1 + v2 - v1 = v2, also

v1’ = v2

Wir erkennen an den letzten beiden Gleichungen, dass für den Spezialfall gleicher Massen die beiden Kugeln ihre Geschwindigkeiten und damit auch ihre Bewegungsenergien tauschen, siehe Abbildung 4:

Elastischer, gerader Stoß für Körper gleicher Massen.

Abbildung 4. Elastischer, gerader Stoß für Körper gleicher Massen.


Somit können wir das folgende Fazit ziehen: Körper gleicher Masse tauschen beim elastischen, geraden Stoß ihre Geschwindigkeitsvektoren aus, Impuls und Bewegungsenergie behalten Betrag und Richtung bei, allerdings übertragen auf den beim Zusammenstoß beteiligten Körper.

1.2. Der schiefe elastische Stoß

Mit diesen Vorbereitungen können wir uns nun dem schiefen, vollkommen elastischen Stoß zuwenden: Bewegen sich zwei Körper schräg aufeinander zu und stoßen zusammen, so spricht man vom schiefen Stoß, siehe zum Beispiel Abbildung 1. Wie beim geraden Stoß kann man auch hier elastische und unelastische Stöße unterscheiden, wir lassen den unelastischen schiefen Stoß weiterhin außer Acht. Zum Zeitpunkt des Zusammenstoßes können wir eine Ebene und eine Normale auszeichnen: Zum einen ist das die Berührungsebene (auch als Tangentialebene bezeichnet), die durch zwei sich berührende Kugeln aufgespannt wird und zum anderen die Stoßnormale (kurz auch als Normale bezeichnet), die durch die beiden Kugelmittelpunkte verläuft und senkrecht auf der Berührungsebene steht. Da Körper, die sich berühren, nur Kräfte aufeinander ausüben, die im Berührungspunkt die Richtung der Stoßnormalen haben, müssen wir beim schiefen Stoß den Geschwindigkeitsvektor in seine Normalen- und Tangentialkomponente zerlegen, siehe Abbildung 5:

Normalen- und Tangentialkomponente.

Abbildung 5. Normalen- und Tangentialkomponente.


Bei einem schiefen elastischen Stoß müssen wir den Geschwindigkeitsvektor v in Bezug auf den Verbindungsvektor der beiden Kugelmittelpunkte in eine Normalenkomponente vn und Tangentialkomponente vt zerlegen. Für die Zerlegung eines Geschwindigkeitsvektors v in seine beiden Anteile vn und vt gilt nun:

  • Die Tangentialkomponente vt bleibt beim schiefen elastischen Stoß unverändert.

  • Die Normalenkomponente vn ändert sich nach den bekannten Stoßgesetzen des zentralen elastischen Stoßes.

Damit können wir als Ergebnis für den schiefen elastischen Stoß zweier Kugeln mit den Geschwindigkeitsvektoren v1 = (v1n, v1t) und v2 = (v2n, v2t) festhalten:

v1 = (v2n, v1t)

v2 = (v1n, v2t)

Betrachten wir in Abbildung 6 den schiefen elastischen Stoß an einem Beispiel, in dem der Verbindungsvektor der Kugelmittelpunkte parallel zur y-Achse des kartesischen Koordinatensystems verläuft (und der Normalenvektor folglich parallel zur x-Achse). Man kann leicht erkennen, wie nach dem Zusammenstoß die neuen Geschwindigkeitsvektoren aus den bereits bekannten Normalen- und Tangentialkomponenten gebildet werden:

Normalen- und Tangentialkomponente.

Abbildung 6. Normalen- und Tangentialkomponente.


Damit können wir diesen kleinen Ausflug in die Physik wieder beenden, einer Umsetzung der Theorie in ein C#/WPF-Programm steht nun nichts mehr im Wege.

2. Lösung

Quellcode: Siehe auch https://github.com/peterloos/Wpf_BouncingBalls.git.

Für die Umsetzung eines WPF-Containers mit sich bewegenden Kugeln bringen wir folgende Klassen ins Spiel:

  • Klasse BouncingBall – Stellt eine bewegte Kugel dar.

  • Klasse Canvas – Container für eine bestimmte Anzahl von BouncingBall-Objekten.

  • Klasse Window – Container für das Canvas-Objekt, Hauptfenster der Anwendung.

  • Klasse Thread – Steuert die Animation der Anwendung.

Ein BouncingBall-Objekt verwaltet zum einen ein Ellipse-Objekt, das die Visualisierung der Kugel vornimmt. Zur Platzierung dieses Ellipse-Objekts besitzt das BouncingBall-Objekt noch zwei Eigenschaften Top und Left, die sich auf die linke obere Ecke des Ellipse-Objekts beziehen und damit seine Position im Container beschreiben. Wenn Sie es genau nehmen, könnten Sie an dieser Stelle argumentieren, dass die beiden Informationen für „Abstand vom oberen Rand des Canvas-Objekts“ und „Abstand vom linken Rand des Canvas-Objekts“ doppelt (und damit redundant) in den Objekten dieser Anwendung gehalten werden: Zum Einen im BouncingBall-Objekt und zum Zweiten im den Ellipse-Objekten, die ihre Position im Container natürlich auch kennen. Der Grund hierfür liegt darin, dass Sie auf die Daten eines BouncingBall-Objekts in jedem Thread der Anwendung zugreifen können, auf die Ellipse-Objekte hingegen nur im UI-Thread der Anwendung.

Zum Bewegen der Kugel enthält das BouncingBall-Objekt zusätzlich einen Richtungsvektor vom Typ Vector. Diese Klasse (genauer: Strukturtyp) gibt es seit dem .NET-Framework in der Version 3.0. Der Richtungsvektor spezifiziert, um welche Distanz das Ellipse-Objekt in einer Zeitscheibe des Animationsthreads relativ zur (Left, Top)–Position zu bewegen ist:

01: namespace BouncingBallsEx
02: {
03:     class BouncingBall
04:     {
05:         public static readonly double Radius = 20;
06: 
07:         private Ellipse circle;   // display shape to display bouncing ball
08:         private Vector direction; // direction vector to move shape
09:         
10:         private double left; // according to 'Left' property of shape
11:         private double top;  // according to 'Top' property of shape
12: 
13:         public BouncingBall(Random rand)
14:         {
15:             // setup shape to display bouncing ball
16:             this.circle = new Ellipse();
17:             this.circle.Width = Radius * 2;
18:             this.circle.Height = Radius * 2;
19:             this.circle.Stroke = Brushes.Red;
20:             this.circle.StrokeThickness = 5;
21: 
22:             // initialize start position by random
23:             this.Left = Radius + 5 * Radius * rand.NextDouble();
24:             this.Top = Radius + 5 * Radius * rand.NextDouble();
25: 
26:             // setup direction vector
27:             this.direction = new Vector(rand.Next(-10, +10), rand.Next(-10, +10));
28:             this.direction.Normalize();
29:         }
30: 
31:         public double Left
32:         {
33:             get { return this.left; }
34:             set
35:             {
36:                 this.left = value;
37:                 this.circle.SetValue(Canvas.LeftProperty, this.left);
38:             }
39:         }
40: 
41:         public double Top
42:         {
43:             get { return this.top; }
44:             set
45:             {
46:                 this.top = value;
47:                 this.circle.SetValue(Canvas.TopProperty, this.top);
48:             }
49:         }
50: 
51:         public Vector Center
52:         {
53:             get
54:             {
55:                 return new Vector (this.left + Radius, this.top + Radius);
56:             }
57:         }
58: 
59:         public Vector Direction
60:         {
61:             set { this.direction = value; }
62:             get { return this.direction; }
63:         }
64: 
65:         public Ellipse Circle
66:         {
67:             get { return this.circle; }
68:         }
69: 
70:         public void NormalizeDirection()
71:         {
72:             this.direction.Normalize();
73:         }
74:     }
75: }

Beispiel 1.1. Klasse BouncingBall.


Das Hauptfenster der Anwendung selbst ist in XAML deklarativ beschrieben – und auf Grund der minimalistischen Oberfläche der Anwendung auch sehr kurz geraten:

<Window x:Class="BouncingBallsEx.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Loaded="BouncingWindow_Loaded" Closing="BouncingWindow_Closing"
        MouseDown="BouncingWindow_MouseDown"
        Title="Bouncing Balls" Height="400" Width="600">
    <Canvas
        Name="BouncingCanvas"
        Background="Black" />
</Window>

Im Code-Behind-Anteil des Hauptfensters liegt der Logik-Anteil für die sich bewegenden Kugeln. Vielfach ist man vor dem Hintergrund eines strukturierten Software-Designs (Stichwort: Kapselung) geneigt, jede Kugel (BouncingBall-Objekt) mit einem separaten Thread auszustatten, der für die Animation (Bewegung der Kugel) zuständig ist. In der Praxis erweist sich dieser Ansatz aber als nicht als zielführend, da auf Grund der vielen Threads die genaue Ortsbestimmung zweier Kugeln zu einem bestimmten Zeitpunkt nicht ganz einfach ist. Oder anders ausgedrückt: Das präzise Abtasten der Lage zweier Kugeln bei vielen Threads kann zum Ruckeln in der grafischen Darstellung führen.

Aus diesem Grund gibt es in der hier vorgestellten Lösung nur einen Thread, der sich für die Bewegung und Kollisionsbestimmung aller Kugeln verantwortlich zeichnet:

001: namespace BouncingBallsEx
002: {
003:     public partial class MainWindow : Window
004:     {
005:         public static readonly int Delay = 5;
006:         public static readonly int MaxBalls = 50;
007: 
008:         // list of bouncing balls
009:         private List<BouncingBall> bouncingBalls;
010: 
011:         // threading utils
012:         private delegate void MoveBallsHandler (BouncingBall ball);
013:         private Thread bouncingThread;
014:         private bool stopped;
015: 
016:         private Random rand;
017: 
018:         public MainWindow()
019:         {
020:             this.InitializeComponent();
021: 
022:             this.bouncingBalls = new List<BouncingBall>();
023:         }
024: 
025:         private void BouncingWindow_Loaded(Object sender, RoutedEventArgs e)
026:         {
027:             // create single random generator for all balls
028:             this.rand = new Random();
029: 
030:             // add first ball
031:             BouncingBall ball = new BouncingBall(this.rand);
032:             this.bouncingBalls.Add(ball);
033:             this.BouncingCanvas.Children.Add(ball.Circle);
034: 
035:             // start bouncing thread
036:             this.bouncingThread = new Thread(this.BouncingThreadProc);
037:             this.stopped = false;
038:             this.bouncingThread.Start();
039:         }
040: 
041:         private void BouncingThreadProc()
042:         {
043:             MoveBallsHandler handler = new MoveBallsHandler(this.MoveBall);
044: 
045:             while (! stopped)
046:             {
047:                 Monitor.Enter(this);
048:                 for (int i = 0; i < this.bouncingBalls.Count; i++)
049:                     this.Dispatcher.BeginInvoke(handler, this.bouncingBalls[i]);
050:                 Monitor.Exit(this);
051: 
052:                 Thread.Sleep(MainWindow.Delay);
053:             }
054:         }
055: 
056:         private void MoveBall(BouncingBall ball)
057:         {
058:             // move ball
059:             ball.Left += ball.Direction.X;
060:             ball.Top += ball.Direction.Y;
061: 
062:             // check boundaries of window and remaining balls
063:             this.CheckBoundaries(ball);
064:             this.CheckCollisions(ball);
065:         }
066: 
067:         private void CheckBoundaries(BouncingBall ball)
068:         {
069:             if (ball.Center.Y < BouncingBall.Radius)
070:             {
071:                 // top wall collision, invert direction vertical         
072:                 if (ball.Direction.Y < 0)
073:                     ball.Direction = new Vector(ball.Direction.X, -ball.Direction.Y);
074:             }
075:             else if (ball.Center.Y > (this.BouncingCanvas.ActualHeight - BouncingBall.Radius))
076:             {
077:                 // bottom wall collision, invert direction vertical                
078:                 if (ball.Direction.Y > 0)
079:                     ball.Direction = new Vector(ball.Direction.X, -ball.Direction.Y);
080:             }
081:             else if (ball.Center.X < BouncingBall.Radius)
082:             {
083:                 // left wall collision, invert direction horizontal
084:                 if (ball.Direction.X < 0)
085:                     ball.Direction = new Vector(-ball.Direction.X, ball.Direction.Y);
086:             }
087:             else if (ball.Center.X > (this.BouncingCanvas.ActualWidth - BouncingBall.Radius))
088:             {
089:                 // left wall collision, invert direction horizontal
090:                 if (ball.Direction.X > 0)
091:                     ball.Direction = new Vector(-ball.Direction.X, ball.Direction.Y);
092:             }
093:         }
094: 
095:         private void CheckCollisions(BouncingBall ball)
096:         {
097:             bool colorswap = false;
098: 
099:             for (int i = 0; i < this.bouncingBalls.Count; i++)
100:             {
101:                 if (Object.ReferenceEquals(ball, this.bouncingBalls[i]))
102:                     continue;
103: 
104:                 Vector difference = ball.Center - this.bouncingBalls[i].Center;
105:                 if (difference.Length <= (BouncingBall.Radius * 2))
106:                 {
107:                     difference.Normalize();
108:                     ball.Direction += difference;
109:                     colorswap = true;
110:                 }
111:             }
112: 
113:             ball.NormalizeDirection();
114: 
115:             if (colorswap)
116:             {
117:                 if (ball.Circle.Stroke == Brushes.Blue)
118:                     ball.Circle.Stroke = Brushes.Red;
119:                 else if (ball.Circle.Stroke == Brushes.Red)
120:                     ball.Circle.Stroke = Brushes.Green;
121:                 else if (ball.Circle.Stroke == Brushes.Green)
122:                     ball.Circle.Stroke = Brushes.Yellow;
123:                 else if (ball.Circle.Stroke == Brushes.Yellow)
124:                     ball.Circle.Stroke = Brushes.White;
125:                 else if (ball.Circle.Stroke == Brushes.White)
126:                     ball.Circle.Stroke = Brushes.Blue;
127:             }
128:         }
129: 
130: 
131:         private void BouncingWindow_MouseDown(Object sender, MouseButtonEventArgs e)
132:         {
133:             if (e.LeftButton == MouseButtonState.Pressed)
134:             {
135:                 if (this.bouncingBalls.Count < MainWindow.MaxBalls)
136:                 {
137:                     BouncingBall ball = new BouncingBall(this.rand);
138: 
139:                     ball.Left = e.GetPosition(this.BouncingCanvas).X - BouncingBall.Radius;
140:                     ball.Top = e.GetPosition(this.BouncingCanvas).Y - BouncingBall.Radius;
141: 
142:                     Monitor.Enter(this);
143:                     this.bouncingBalls.Add(ball);
144:                     this.BouncingCanvas.Children.Add(ball.Circle);
145:                     Monitor.Exit(this);
146:                 }
147:             }
148:             else if (e.RightButton == MouseButtonState.Pressed)
149:             {
150:                 int count = this.bouncingBalls.Count;
151:                 if (count == 0)
152:                     return;
153: 
154:                 Monitor.Enter(this);
155:                 this.bouncingBalls.RemoveAt(count - 1);
156:                 this.BouncingCanvas.Children.RemoveAt(count - 1);
157:                 Monitor.Exit(this);
158:             }
159:         }
160: 
161:         private void BouncingWindow_Closing(Object sender, CancelEventArgs e)
162:         {
163:             this.stopped = true;
164:             this.bouncingThread.Join();
165:         }
166:     }
167: }

Beispiel 1.2. Klasse MainWindow: Code-Behind.


Werfen Sie in Listing 1.2 bitte einen Blick auf die Zeilen 47 und 50, 142 und 145 bzw. 154 und 157: Mit Hilfe der beiden Aufrufe von Monitor.Enter und Monitor.Exit wird in den beiden Methoden BouncingThreadProc und BouncingWindow_MouseDown ein kritischer Abschnitt geschützt. Die BouncingThreadProc-Methode wird ausschließlich im UI-Thread der Anwendung aufgerufen. Im Besonderen nimmt sie Änderungen an zwei Listen der Anwendung vor: List<BouncingBall>-Liste mit Referenzen von BouncingBall-Objekten sowie die im Canvas-Objekt enthaltene Liste aller im Container enthaltenen Steuerelemente (Name Children).

Durch sukzessives Drücken des Mauszeigers wird die BouncingWindow_MouseDown-Methode ausschließlich (sequentiell) im UI-Thread der Anwendung aufgerufen, was zunächst einmal kein Problem darstellt. Echt- oder quasi-parallel werden aber diese beiden Listen im Sekundärthread der Anwendung traversiert, um die Positionen der Ellipse-Objekte zu ändern. Dieses kann zu Abstürzen in der Anwendung führen, wenn während des Traversierens die internen Verwaltungsdaten in diesen beiden Listen durch Hinzufügen oder Löschen von BouncingBall-Objekten verändert werden!