Animierter Tunnel mit Kurven (Javascript Canvas) Frameworks Javascript Canvas 

Animierter Tunnel mit Kurven (Javascript Canvas)

Vor ein paar Monaten habe ich diesen animierten Tunnel programmiert:

See the Pen Simple 3d tunnel with curves by Anna Prenzel (@blaustern_fotografie) on CodePen.

Hintergrund: Vorher hatte ich (auf Anregung meiner Mama) den animierten Sternenhimmel programmiert, den ich in diesem Beitrag beschrieben habe. Dabei lernte ich die perspektivische Projektion kennen, mit der sich dreidimensionale Punkte auf dem zweidimensionalen Bildschirm darstellen lassen. Auf einmal öffnete sich für mich ein Tor zur 3D-Grafikprogrammierung, ein Gebiet, mit dem ich mich vorher noch nicht beschäftigt hatte. Mein Interesse war geweckt. Das nächste Projekt sollte ein 3D-Tunnel werden, natürlich mit Kurven. Jetzt komme ich endlich dazu, den Code zu dokumentieren und euch meine Vorgehensweise zu erläutern.

Vorbereitung

Zuerst lege ich den benötigten HTML-Code an. Er enthält das <canvas>-Tag, also die “Leinwand”, auf die ich später mit Javascript “zeichnen” werde. Ich speichere die Seite z.B. unter dem Namen “tunnel.html” (weitere Infos zum Umgang mit HTML-Dateien gibt es hier).

Im Head-Bereich verlinke ich die Javascript-Datei, in die ich den Code der Canvas-Animation einfügen werde. Die Datei befindet sich im gleichen Ordner wie die HTML-Datei.

<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="canvas_stars.js"></script>
</head>

<body style="background-color:#1d1d1d">

<canvas id="myCanvas" width="100" height="100" style="background-color: #1d1d1d">
Your browser does not support the HTML5 canvas tag.</canvas>

</body>
</html>

Als nächstes öffne ich die Javascript-Datei und füge folgende Anweisungen ein:

var c = document.getElementById('myCanvas');
c.width = window.innerWidth;
c.height = window.innerHeight;

var ctx = c.getContext('2d');
ctx.strokeStyle = '#ffff99';
ctx.lineWidth = 2;

var Point = {
 x: 0,
 y: 0,
 dist: 0
};

var x_off = c.width / 2;
var y_off = c.height / 2;

//distance between eye and view screen
var d_strich = 500;

//distance between the squares
var stepwidth = 50;

Der Code sorgt als Erstes dafür, dass das Canvas die volle Breite und Höhe des Browserfensters (“window”) einnimmt.

Mit dem Aufruf “getContext” erhalte ich ein Objekt, welches Anweisungen zum Zeichnen im Canvas entgegennimmt. Ich nenne es “ctx”.

Ich lege einen Objektprototyp “Point” an, der die x- und y-Koordinaten eines Punktes im Canvas und die virtuelle Entfernung “dist” zusammenfasst.
 
Zu den Variablen “x_off” und “y_off” gibt es folgende Erklärung: Jedes Quadrat im Tunnel hat seinen Mittelpunkt im Koordinatenursprung, d.h. jeder der vier Eckpunkte liegt in einem anderen Quadranten. Im Canvas befindet sich der Ursprung x=0,y=0 jedoch in der linken oberen Ecke. Damit die Quadrate in der Canvas-Mitte zu sehen sind, werden ihre x- und y-Koordinaten beim Zeichnen um die Hälfte der Breite nach rechts (x_off) und um die Hälfte der Höhe nach unten (y_off) verschoben.
 
Außerdem benötige ich die folgenden drei Funktionen:

function draw_rect(lt, lb, rt, rb) {

  ctx.beginPath();

  ctx.moveTo(lt.x + x_off, lt.y + y_off);
  ctx.lineTo(lb.x + x_off, lb.y + y_off);
  ctx.lineTo(rb.x + x_off, rb.y + y_off);
  ctx.lineTo(rt.x + x_off, rt.y + y_off);
  ctx.lineTo(lt.x + x_off, lt.y + y_off);
  ctx.stroke();
  ctx.closePath();
}

draw_rect zeichnet ein Rechteck in das Canvas, wobei die vier Eckpunkte (lt=left top, lb=left bottom, rt=right top, rb=right bottom) als Point-Objekte übergeben werden müssen.

function project2d(point) {
  var p = Object.create(Point);
  p.x = Math.round(d_strich * point.x / (point.dist + d_strich));
  p.y = Math.round(d_strich * point.y / (point.dist + d_strich));
  p.dist = point.dist;
  return p;
}

project_2d rechnet die x- und y-Koordinaten eines dreidimensionalen Punktes in den zweidimensionalen Raum um. Auf dem Bildschirm entsteht ein 3D-Effekt, weil Punkte, die im 3D-Raum weiter vom Bildschirm entfernt sind, im 2D-Raum näher am Koordinatenursprung abgebildet werden. Das führt z.B. dazu, dass Quadrate, die im Tunnel weiter hinten liegen, kleiner dargestellt werden, als die vorn liegenden Quadrate. Details zum Verfahren gibt es in diesem Beitrag. Dort wird auch die Rolle der globalen Variable “d_strich” erläutert.

//see http://www-lehre.informatik.uni-osnabrueck.de/~mm/skript/7_3_3D_Transformation.html
function rotate3d(point, angle) {
  var p = Object.create(Point);
  p.y = point.y;
  p.x = point.dist * Math.sin(angle) + point.x * Math.cos(angle);
  p.dist = point.dist * Math.cos(angle) - point.x * Math.sin(angle);
  return p;
}

rotate3d rotiert das Koordinatensystem eines Punktes um die y-Achse. Die Funktion wird benötigt, um die Quadrate des Tunnels schräg zu stellen, was wiederum notwendig ist, um die Kurven zu generieren.
 
Zum Schluss entwickle ich noch einen Objekt-Prototyp für ein Tunnelsegment, d.h. für ein Quadrat mit einer bestimmten Entfernung vom Bildschirm:

var Square = {

  p1: Object.create(Point),
  p2: Object.create(Point),
  p3: Object.create(Point),
  p4: Object.create(Point),
  width: 0,
  height: 0,
  angle: 0,

  draw: function() {
  	
	//don't draw squares that are not visible
	if(this.p1.dist >= (-d_strich + 1)){
		
		var lt = this.p1;
		var lb = this.p2;
		var rt = this.p3;
		var rb = this.p4;

		lt = rotate3d(lt, this.angle);
		lb = rotate3d(lb, this.angle);
		rt = rotate3d(rt, this.angle);
		rb = rotate3d(rb, this.angle);
		
		//the size and position of the square depends on its distance to the view screen
		lt = project2d(lt);
		lb = project2d(lb);
		rt = project2d(rt);
		rb = project2d(rb);

		draw_rect(lt, lb, rt, rb);	
       }else {
                //remove square as soon as it is not visible anymore
                var index = elements.indexOf(this);
                elements.splice(index, 1);
       }	
  }
};

Funktion “draw”: Jedes Tunnelsegment, also “Square”-Objekt, speichert seine 4 Eckpunkte p1,…,p4 sowie Breite, Höhe und Neigungswinkel. Da die Eckpunkte zunächst im 3D-Raum definiert werden, müssen sie zuerst rotiert und in den 2D-Raum projiziert werden, bevor das Quadrat durch einen Aufruf von “draw_rect” tatsächlich gezeichnet werde kann.

Schritt 1: Die Quadrate des Tunnels generieren

Als erstes soll ein einfacher, nicht animierter 3D-Tunnel ohne Kurven erstellt werden. Ich generiere eine große Anzahl von Tunnelsegmenten (Square-Objekte) und speichere sie in einem Array ab. Wichtig ist, dass die Entfernung der Quadrate vom Bildschirm schrittweise zunimmt.

//list of all squares of the tunnel
var elements = [];

function createSquares() {
 
  for (var i = 0; i <= 2000; i = i + stepwidth) {

    var elem = Object.create(SquareElement);
	
    elem.p1 = Object.create(Point);
    elem.p1.x = -100;
    elem.p1.y = -100;
    elem.p1.dist = (i-d_strich)+1;

    elem.p2 = Object.create(Point);
    elem.p2.x = -100;
    elem.p2.y = 100;
    elem.p2.dist = (i-d_strich)+1;

    elem.p3 = Object.create(Point);
    elem.p3.x = 100;
    elem.p3.y = -100;
    elem.p3.dist = (i-d_strich)+1;

    elem.p4 = Object.create(Point);
    elem.p4.x = 100;
    elem.p4.y = 100;
    elem.p4.dist = (i-d_strich)+1;

    elements.push(elem);
  }
}

Die update-Funktion ist dafür zuständig, für jedes Quadrat die "draw"-Funktion aufzurufen.

function update() {
 
  elements.forEach(function(elem, i, arr) {
    
    elem.draw();
  });
}

createSquares();
update();

Den aktuellen Stand des Programms können Sie hier ausprobieren:



Your browser does not support the HTML5 canvas tag.

Schritt 2: Kurven

Jetzt musste ich mir noch etwas einfallen lassen, damit der Tunnel Kurven bekommt. Dafür gibt es sicher verschiedene, in Bezug auf Programmschnelligkeit erprobte Möglichkeiten. Da der Tunnel nur eine einfache Fallstudie sein soll, wählte ich folgenden Ansatz: Jedes Tunnelsegment mit der Nummer "i" (vgl. Funktion "createSquares") bekommt eine bestimmte Neigung "angle", die sich aus einer Kosinusfunktion ergibt:

angle = a*cos(b + c*i);

Die Parameter a, b und c bestimmen jeweils die Stauchung in vertikaler Richtung, die Verschiebung in horizontaler Richtung und die Stauchung in horizontaler Richtung. Beispiele finden Sie in diesem Artikel. Die Parameter können Sie in dem folgenden Beispiel selbst ändern, um die Kurven zu modifizieren (in der Funktion "createSquares").
 


Your browser does not support the HTML5 canvas tag.

Schritt 3: Den Tunnel animieren

Im nächsten Schritt soll sich der Tunnel auf den Bildschirm zu bewegen. Zuerst entferne ich den Aufruf "update();" in der letzten Zeile des Programms und ersetze ihn durch

setInterval(update, 5);

Nun wird die update-Funktion alle 5ms ausgeführt. Das Intervall können Sie natürlich ändern. Mehr über die Grundlagen der Canvas-Animation können Sie beim Mozilla Developer Network erfahren.
 
In jedem Intervall müssen sich alle Quadrate ein Stückchen auf den Bildschirm zu bewegen. Also muss die update-Funktion so erweitert werden, dass die Entfernung "dist" aller Eckpunkte jeweils um einen konstanten Wert reduziert wird.

function update() {
  ctx.clearRect(0, 0, c.width, c.height);

  elements.forEach(function(elem, i, arr) {
    elem.p1.dist -= 1;
    elem.p2.dist -= 1;
    elem.p3.dist -= 1;
    elem.p4.dist -= 1;
    elem.draw();
  });
}

Quadrate, die nicht mehr sichtbar sind, weil sie über den Bildschirmrand hinaus gewandert sind, werden gelöscht (siehe Funktion "draw" im Square-Prototyp). Wenn die Animation läuft, gehen also nach und nach Quadrate verloren. Ich muss dafür sorgen, dass im Abstand der ursprünglichen Schrittweite ganz hinten am Ende des Tunnels ständig neue Quadrate generiert werden. Die update-Funktion sieht also insgesamt so aus:

var counter = 0;

function update() {
  ctx.clearRect(0, 0, c.width, c.height);

  //each square gets one pixel closer to the screen
  elements.forEach(function(elem, i, arr) {
    elem.p1.dist -= 1;
    elem.p2.dist -= 1;
    elem.p3.dist -= 1;
    elem.p4.dist -= 1;
    elem.draw();
  });

  counter++;
  i_counter++;

  //generate a new square at the farthest distance
  if (counter == stepwidth) {
    var elem = Object.create(Square);
    elem.angle = Math.cos(1.575 + 0.5 * i_counter) * 0.3;
    elem.p1 = Object.create(Point);
    elem.p1.x = -100;
    elem.p1.y = -100;
    elem.p1.dist = 2000 - d_strich + 1;

    elem.p2 = Object.create(Point);
    elem.p2.x = -100;
    elem.p2.y = 100;
    elem.p2.dist = 2000 - d_strich + 1;

    elem.p3 = Object.create(Point);
    elem.p3.x = 100;
    elem.p3.y = -100;
    elem.p3.dist = 2000 - d_strich + 1;

    elem.p4 = Object.create(Point);
    elem.p4.x = 100;
    elem.p4.y = 100;
    elem.p4.dist = 2000 - d_strich + 1;

    elements.push(elem);
    counter = 0;
  }
}

In der Kosinus-Funktion kommt eine neue Variable "i_counter" zum Einsatz. Der Wert dieser Variable wird jedesmal, wenn ein neues Quadrat erstellt wird, um eins erhöht. Die fortlaufende Nummerierung stellt sicher, dass sich die Quadrate nahtlos in die Kosinuskurve einfügen. Die Funktion "createSquares" wurde im folgenden Codebeispiel ebenfalls aktualisiert, da dort die Variable "i_counter" initialisiert wird.



Your browser does not support the HTML5 canvas tag.

Achtung: Es kommt zu Problemen, wenn Sie mehrmals auf "Ausführen" klicken. Wenn Sie etwas an der Animation ändern möchten, laden Sie bitte zuerst die Seite neu und ändern Sie den Code, bevor Sie auf "Ausführen" klicken.

Schritt 4: Ein Lenkmechanismus

Im letzten Beispiel hat man nicht gerade das Gefühl, selbst durch den Tunnel zu fahren. Am Anfang war mir nicht ganz klar, wie man so etwas simuliert. Mir hat es geholfen, auf Youtube Videos von Führerstandsmitfahrten anzusehen:
 
Beispielvideo Führerstandsmitfahrt
 
Dort habe ich genau beobachtet, was in den Kurven passiert: Vorn im Video sind die Bahngleise immer gerade ausgerichtet. Das bleibt auch so, wenn eine Kurve kommt, denn der Zug biegt ja in die Kurve ein und hat somit immer die gleiche Richtung wie die Bahngleise.
 
Für meine Simulation leite ich daraus ab, dass das am weitesten vorn liegende Quadrat immer gerade ausgerichtet werden muss. Das heißt: der ganze Tunnel muss in jedem Animationsschritt so gedreht werden, dass das vordere Tunnelsegment möglichst gerade bleibt. Dann kann man stets von vorn in den Tunnel hineinblicken und hat den Eindruck, dass man sich selbst dreht bzw. den Wagen lenkt, während sich in Wirklichkeit der Tunnel dreht.
 
Die Implementierung habe ich folgendermaßen gestaltet: Ich führe eine globale Variable "closest_element" ein, die stets das Square-Objekt abspeichert, welches am weitesten vorn liegt. Jedes Square-Objekt überprüft beim Aufruf der draw-Funktion, ob es selbst am weitesten vorn liegt. Wenn ja, dann ist es das aktuelle "closest_element":

...
draw: function() {

	if(this.p1.dist >= (-d + 1)){
		
		var lt = this.p1;
		var lb = this.p2;
		var rt = this.p3;
		var rb = this.p4;

		lt = rotate3d(lt, this.angle);
		lb = rotate3d(lb, this.angle);
		rt = rotate3d(rt, this.angle);
		rb = rotate3d(rb, this.angle);
		
		//the size and position of the square depends on its distance to the view screen
		lt = project2d(lt);
		lb = project2d(lb);
		rt = project2d(rt);
		rb = project2d(rb);

		draw_rect(lt, lb, rt, rb);
		
		if(this.p1.dist < -390){
	          //I am the square that is closest to the screen
		  closest_element = this;
		}    		
	}else{
		//remove square as soon as it is not visible anymore
		var index = elements.indexOf(this);
		elements.splice(index, 1);
	}
  }

Ich habe hier eine Entfernung von -390 als Grenze veranschlagt. Sie können natürlich andere Werte ausprobieren, wobei die Konstante auch mit dem Wert von "d_strich" in Beziehung steht.
 
Als nächstes wird die update-Funktion erweitert:

...
 //are we in a curve to the right?
  if(closest_element != null && closest_element.angle > 0){
	   
	  //change the camera (viewer) perspective - like turning right
	  elements.forEach(function(elem, i, arr) {
           
		  elem.angle = elem.angle - 0.002;
	  });
	   
  }
   
  //are we in a curve to the left?
  if(closest_element != null && closest_element.angle < 0){
	   
	  //change the camera (viewer) perspective - like turning left
	  elements.forEach(function(elem, i, arr) {
           
		  elem.angle = elem.angle + 0.002;
	  });
	   
  }
 
  closest_element = null;

Ich habe zwei If-Anweisungen erstellt, um zu prüfen, ob das "closest_element" einen Winkel größer als 0 hat. Wenn das der Fall ist, ist es nicht ganz gerade ausgerichtet. Daher muss der ganze Tunnel ein Stückchen gedreht werden, und zwar so, dass der Winkel des "closest_element" dem Wert 0 ein Stückchen näher kommt. Bei meinem Ansatz können also mehrere Animationsintervalle nötig sein, bis das "closest_element" gerade ausgerichtet ist. Auch die Schrittweite 0.002 zur Anpassung des Winkels kann ggf. angepasst werden. Je größer der Wert, desto stärker ruckelt die Animation.
 
Und hier das fertige Ergebnis:



Your browser does not support the HTML5 canvas tag.

Achtung: Es kommt zu Problemen, wenn Sie mehrmals auf "Ausführen" klicken. Wenn Sie etwas an der Animation ändern möchten, laden Sie bitte zuerst die Seite neu und ändern Sie den Code, bevor Sie auf "Ausführen" klicken.

Fazit

Für mich war dieses Projekt sehr spannend und ich bin fasziniert von meinem ersten kleinen 3D-Programm 😉 Natürlich ist die Animation nicht sehr flüssig. Es gibt tolle Javascript-Bibliotheken, mit denen sich viel spektakulärere (Tunnel-)Animationen erschaffen lassen. Ich wollte aber unbedingt mit reinem Javascript (auch genannt "Vannilla JS") arbeiten, weil es mir wichtig war, alle angewendeten mathematischen Prinzipien bis ins kleinste Detail zu verstehen. Wenn man eine Bibliothek anwendet, verbringt man hauptsächlich Zeit damit, die Handhabung dieser Bibliothek zu erlernen, während man von der zugrunde liegenden Mathematik fast nichts erfährt. Das war in diesem Fall nicht mein Ziel.
 
Über Fragen, Anregungen oder Korrekturen freue ich mich immer!

Related posts