Genug Mathematik, auf zur Implementierung mit Code!
Datei-Setup
Alles was wir brauchen, ist eine leere HTML-Datei. Diese lässt sich direkt mit Google Chrome, Firefox etc. öffnen.
HTML-Code
Ganz oben in der Datei definieren wir einen Canvas mit einer ID. Den Inhalt der ID können wir uns selbst aussuchen.
Darunter erstellen wir den Script-Tag, der unser JavaScript enthalten wird:
<canvas id="particles" style="background-color: black;"></canvas>
<script>
</script>
Code kopieren
Die clamp-Funktion schränkt den Wert nochmal zwischen 0 und der Breite bzw. Höhe des Canvas ein.
Der entsprechende Codesieht so aus:
function clamp(val, min, max) {
if (val < min) return min;
if (val > max) return max;
return val;
}
Code kopieren
Partikel erstellen
Jetzt generieren wir die Partikel mit zufälligen Werten und speichern diese in einem Array.
Zufällig sind hier jeweils Position und Velocity, wobei die Werte der Position irgendwo zwischen 0 und der Breite/Höhe des Canvas liegen können.
Die Velocity liegt jedoch nur zwischen -0.5 und 0.5. Wir multiplizieren diese dann mit der Partikelgeschwindigkeit.
Hier müssen wir die Velocity noch normalisieren, damit sich alle Partikel gleich schnell, aber in verschiedene Richtungen bewegen:
function createParticles() {
for (let i = 0; i < particleAmount; i++) {
//zufällige Position erstellen
let x = Math.random() * canvasWidth;
let y = Math.random() * canvasHeight;
let position = new vector2(x, y);
//zufällige Velocity erstellen
let dx = (Math.random() - 0.5) * particleSpeed;
let dy = (Math.random() - 0.5) * particleSpeed;
let velocity = new vector2(dx, dy);
//normalisieren
velocity = velocity.normalize();
particles.push(new particle(position, velocity, particleRadius, particleColor));
}
}
Code kopieren
Fast fertig!
Partikel animieren
Wir müssen die Funktion zum Animieren des Canvas immer wieder neu aktualisieren, um Bewegungen darstellen zu können.
Sonst hätten wir einfach nur ein statisches Bild.
Jedes Partikel muss dann einmal die Distanz zu jedem anderen Partikel überprüfen.
Wenn diese Distanz klein genug ist, zeichnen wir eine Linie zwischen den Partikeln. Erst nach dieser Berechnung dürfen sich die Partikel bewegen.
Das Ganze ist allerdings sehr rechenintensiv.
Bei 1000 Partikeln hätten wir ca. O(n2 ) - 1 = (1000 * 1000) - 1 = 999.999 Berechnungen,
da jedes Partikel jedes andere Partikel außer sich selbst überprüfen muss.
Einen besseren Ansatz spreche ich noch am Ende meines Blogbeitrags an.
Hier der Code, mit dem wir die Partikel animieren können:
function animate() {
//animate Funktion immer wieder aktualisieren
requestAnimationFrame(animate);
//letztes Frame leeren, um das zu verstehen, die Zeile einfach mal auskommentieren und testen
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
//für jedes Partikel
for (let i = 0; i < particleAmount; i++) {
//hole die Position des aktullen Partikels
let currentPosition = particles[i].pos;
//Für jedes andere Partikel
for (let j = 0; j < particleAmount; j++) {
//Außer für sich selbst
if (particles[i] === particles[j]) continue;
//wenn i + j kleiner als unsere Partikelanzahl, dann nimm die Position des "i+j-ten" Partikels, sonst fange von hinten wieder an
let nextPosition = (i + j) < particleAmount - 1 ? particles[i + j].pos : particles[particleAmount - j - 1].pos;
//Distanz zwischen den beiden Partikeln berechnen
let distX = Math.abs(nextPosition.x - currentPosition.x);
let distY = Math.abs(nextPosition.y - currentPosition.y);
//wenn Distanz klein genug, dann zeichne eine Linie
if (distX <= maxDistBetweenParticles && distY <= maxDistBetweenParticles) {
ctx.beginPath();
//starte beim aktuellen Partikel
ctx.moveTo(currentPosition.x, currentPosition.y);
//und ziehe eine Linie zum nächsten
ctx.lineTo(nextPosition.x, nextPosition.y);
//Zeichne die Linie mit unserer Farbe
ctx.strokeStyle = lineColor;
ctx.stroke();
}
}
//erst nach allen Berechnungen Partikel aktualisieren
particles[i].update(ctx);
}
}
Code kopieren
Fertig!
Der Effekt ist nun fertig. Um ihn noch etwas zu verbessern, könnten wir z. B. mehrere Farben hinzufügen oder das Canvas responsiv machen etc.
Das ist jetzt allerdings Eure Aufgabe! ;-)
Partikel effizienter animieren
In diesem Blog genutzte Methode
Wie vorhin schon angemerkt, zählen wir ca. 1 Millionen Berechnungen bei 1000 Partikeln.
Da unser Vorgehen eine Komplexität von ca. O(n2) hat, steigt die Anzahl der Berechnungen exponentiell.
Für große Partikelmengen ist diese Methode also nicht geeignet und insbesondere auf Mobilgeräten nur sehr eingeschränkt nutzbar.
Die Lösung heißt " Quadtree"!
Ein Quadtree ist eine Daten-/Baumstruktur, mit der wir einen 2D-Raum einteilen können.
Hier ein Beispiel aus einer Implementation von mir:
Die grünen Quadrate symbolisieren jeweils einzelne Knoten der Baumstruktur und können wiederum in 4 weitere Quadrate aufgeteilt werden, sobald zu viele Partikel in einem Quadrat sind.
Hier muss also nicht jedes Partikel alle anderen überprüfen. Stattdessen kann jedes Partikel die jeweiligen Partikel in seiner Nähe abfragen.
Mit diesem Ansatz sinkt die Komplexität auf ca. O (n * log10(n)) und bei 1000 Partikeln auf ca. 1000 * log10(1000) = 3000.
Grafischer Vergleich
Hier seht Ihr eine grafische Darstellung der Anzahl der Berechnungen beider Ansätze.
Grün steht für den alten Ansatz und Rot für den Quadtree-Ansatz:
Screenshot von www.geogebra.org/classic
Einen Quadtree könnt Ihr ebenfalls in einem dreidimensionalem Raum anwenden.
Dann heißt diese Baumstruktur Octree.
Weiterführende Infos zu Quadtree und Octree findet Ihr hier:
https://www.youtube.com/watch?v=OKiBmQ6ZNyU
Falls Ihr darüber hinaus Interesse habt, selbst einen Quadtree zu implementieren, empfehle ich Euch abschließend auch dieses Video:
https://www.youtube.com/watch?v=OJxEcs0w_kE
HIER DER VOLLSTÄNDIGE CODE:
<canvas id="particles" style="background-color: black;"></canvas>
<script>
vector2 = function (x, y) {
this.x = x;
this.y = y;
this.length = () => {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
this.normalize = () => {
let len = this.length();
return new vector2(this.x / len, this.y / len);
}
}
particle = function (position, velocity, radius, color = "white") {
this.pos = position;
this.vel = velocity;
this.radius = radius;
this.color = color;
this.draw = (ctx) => {
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = this.color;
ctx.fill();
}
this.basicMovement = () => {
//wenn wir die Grenzen vom Canvas erreichen, soll die Richtung geändert werden!
if (this.pos.x + this.radius > canvasWidth || this.pos.x - this.radius < 0) this.vel.x *= -1;
if (this.pos.y + this.radius > canvasHeight || this.pos.y - this.radius < 0) this.vel.y *= -1;
this.pos.x += this.vel.x;
this.pos.y += this.vel.y;
}
this.update = (ctx) => {
//wenn wir Partikel nicht verschieben wollen, oder die Maus nicht auf dem Canvas ist
if (!pushParticlesWithMouse || (mouse.x === undefined && mouse.y === undefined)) {
//Normale bewegung mittels unserer Velocity
this.basicMovement();
} else {
//Partikel mit Maus wegbewegen
//Vektor von Partikel zur Maus
let particleToMouse = new vector2(mouse.x - this.pos.x, mouse.y - this.pos.y);
//Damit ist die Distanz zur Maus die Länge des Vektors
let distance = particleToMouse.length();
//Jetzt wird der Vektor normalisiert (durch seine Länge geteilt) -> also seine Länge wird jetzt 1 sein
let force = new vector2(particleToMouse.x / distance, particleToMouse.y / distance);
//wenn wir zu nah an der Maus sind
if (distance <= maxDistToMouse) {
//Wieder auf die größe des Canvas achten
if (this.pos.x + this.radius > canvasWidth || this.pos.x - this.radius < 0) force.x = 0;
if (this.pos.y + this.radius > canvasWidth || this.pos.y - this.radius < 0) force.y = 0;
//jetzt können die Partikel verschoben werden
this.pos.x -= force.x * pushForce;
this.pos.y -= force.y * pushForce;
} else {
//wenn wir nicht mit zu nah an der Maus sind
//berechne unsere Position voraus auf die wir uns zu bewegen mit unserer Geschwindigkeit
let desiredPos = new vector2(this.pos.x + this.vel.x, this.pos.y + this.vel.y);
//Vektor von berechneter Position zur Maus
let desiredToMouse = new vector2(mouse.x - desiredPos.x, mouse.y - desiredPos.y);
//Berechne erneut unsere Distanz zur Maus ausgehend von unserer vorausgerechneten Position
let distance = desiredToMouse.length();
//wenn wir nun wieder zu nah der Maus sind
if (distance <= maxDistToMouse) {
//Kehre um
this.vel.x *= -1;
this.vel.y *= -1;
} else {
//Sonst bewege dich normal weiter
this.basicMovement();
}
}
}
//Partikel anzeigen lassen
this.draw(ctx);
}
}
const canvas = document.getElementById("blog_particles"); //! Hier die ID des Canvas im HTML-Code eingeben
const canvasWidth = 574; //beliebiger Wert für die Breite des Canvas
const canvasHeight = 270;//beliebig Wert für die Höhe des Canvas
canvas.style.maxWidth = canvasWidth + "px";
canvas.style.maxHeight = canvasHeight + "px";
const pixelRatio = window.devicePixelRatio;
canvas.width = pixelRatio * canvasWidth;
canvas.height = pixelRatio * canvasHeight;
let ctx = canvas.getContext("2d"); //Wird benutzt um später Objekte zu zeichnen auf dem Canvas
ctx.scale(pixelRatio, pixelRatio);
const mouse = new vector2(0, 0);
const particleColor = "crimson";
const lineColor = "white";
const particles = [];
const particleAmount = 50;
const particleRadius = 2;
const particleSpeed = 1.5;
const maxDistBetweenParticles = 50; //Distanz in pixel zwischen Partikeln, damit eine Linie gezeichnet wird
const maxDistToMouse = 50; //Distanz in pixel zwischen Partikel und Maus, damit Partikel weggeschoben werden
const pushParticlesWithMouse = true;
const pushForce = 8; //Kraft mit der Partikel weggeschoben werden
addMouseEvents();
createParticles();
animate();
function animate() {
//animate Funktion immer wieder aktualisieren
requestAnimationFrame(animate);
//letztes Frame leeren, um das zu verstehen, die Zeile einfach mal auskommentieren und testen
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
//für jedes Partikel
for (let i = 0; i < particleAmount; i++) {
//hole die Position des aktullen Partikels
let currentPosition = particles[i].pos;
//Für jedes andere Partikel
for (let j = 0; j < particleAmount; j++) {
//Außer für sich selbst
if (particles[i] === particles[j]) continue;
//wenn i + j kleiner als unsere Partikelanzahl, dann nimm die Position des "i+j-ten" Partikels, sonst fange von hinten wieder an
let nextPosition = (i + j) < particleAmount - 1 ? particles[i + j].pos : particles[particleAmount - j - 1].pos;
//Distanz zwischen den beiden Partikeln berechnen
let distX = Math.abs(nextPosition.x - currentPosition.x);
let distY = Math.abs(nextPosition.y - currentPosition.y);
//wenn Distanz klein genug, dann zeichne eine Linie
if (distX <= maxDistBetweenParticles && distY <= maxDistBetweenParticles) {
ctx.beginPath();
//starte beim aktuellen Partikel
ctx.moveTo(currentPosition.x, currentPosition.y);
//und ziehe eine Linie zum nächsten
ctx.lineTo(nextPosition.x, nextPosition.y);
//Zeichne die Linie mit unserer Farbe
ctx.strokeStyle = lineColor;
ctx.stroke();
}
}
//erst nach allen Berechnungen Partikel aktualisieren
particles[i].update(ctx);
}
}
function addMouseEvents() {
//Koordinaten unseres Canvas relativ zum HTML-Dokument und Scroll Position
const canvasRect = canvas.getBoundingClientRect();
const offset = {
top: canvasRect.top + window.scrollY,
left: canvasRect.left + window.scrollX,
}
canvas.addEventListener('mousemove', function (event) {
//Mausposition relativ zum Canvas
mouse.x = event.pageX - offset.left;
mouse.y = event.pageY - offset.top;
mouse.x = clamp(mouse.x, 0, canvasWidth);
mouse.y = clamp(mouse.y, 0, canvasHeight);
});
canvas.addEventListener('mouseleave', function () {
mouse.x = undefined;
mouse.y = undefined;
});
}
function createParticles() {
for (let i = 0; i < particleAmount; i++) {
//zufällige Position erstellen
let x = Math.random() * canvasWidth;
let y = Math.random() * canvasHeight;
let position = new vector2(x, y);
//zufällige Velocity erstellen
let dx = (Math.random() - 0.5) * particleSpeed;
let dy = (Math.random() - 0.5) * particleSpeed;
let velocity = new vector2(dx, dy);
//normalisieren
velocity = velocity.normalize();
particles.push(new particle(position, velocity, particleRadius, particleColor));
}
}
function clamp(val, min, max) {
if (val < min) return min;
if (val > max) return max;
return val;
}
</script>
Code kopieren