Dynamic gesture particles
In diesem Tutorial lernst du, mithilfe von p5.js Bibliotheken, deine eigene interaktive und bunte Partikelwelt zu entwickeln, bei der du selber bestimmen kannst, welche Farben, Partikelgröße- und Geschwindigkeit deine Walker haben sollen. Mit Hand- und Eyetracking bewegst du sie selbst und kannst diese auch zum Explodieren bringen!
Die Idee

Was sind Dynamic gesture Particles?
Die Idee basiert auf einem generativen, abstrakten Partikelsystem mit regelbarer Anzahl an Partikeln, sowie Größe, Geschwindigkeit und Farbe. Durch Gestensteuerung oder Eyetracking kann die Bewegung der einzelnen Partikel beeinflusst werden oder Effekte wie Explosionen ausgelöst werden. So kann beispielsweise durch ein Blinzeln beim Eyetracking eine Explosion erzeugt werden.
In diesem Projekt sollte die Steuerung bewusst ohne klassische Eingabegeräte erfolgen. Stattdessen setzten wir von Anfang an auf Handtracking und ergänzten später Eyetracking, um die Interaktion noch intuitiver zu gestalten.

Das Tutorial basiert auf einer Kombination aus verschiedenen Elementen, bei der die Interaktivität im Mittelpunkt stand:
- Interaktivität (Gestensteuerung)
- Benutzeroberfläche (UI)
- Webcam-Intergration
Steuerung
-
Handgestensteuerung
• Zeigefinger = Wird blau angezeigt; Partikelsteuerung
• Faust = Lilane Farbe; löst Explosion aus
-
Eyetracking
• Pupillenbewegung = Neon cyan; Partikelsteuerung
• Blinzeln = Löst Explosion aus
-
Cursor Pointer
• Klicken der Maus = Löst Explosion aus
• Bedienung der Regler rechts; Farbauswahl
Explosion durch Faust | Handtracking

Ballt man die Hand zu einer Faust zusammen, färben sich die Punkte auf der linken oberen Webcam-Anzeige lila und die Partikel explodieren an einer Stelle.
Explosion durch Blinzeln | Eyetracking

Die Pupillen werden in der Webcam in einem leuchtenden Cyan-Neonfarbton dargestellt. Passend zur Blickrichtung erscheint ebenfalls ein leuchtender, cyanfarbener Punkt. Blinzelt man, wird wie im vorherigen Beispiel ein Effekt ausgelöst.
Wie setzen wir das um?
Im Folgenden lernst du, wie du das Partikelsystem technisch umsetzt – von HTML-Grundgerüst, über Hand- und Eyetracking bis zur Farbauswahl.
1. Überblick | Was brauchen wir?
- HTML-Gerüst & Webcam einrichten
- Partikelsystem mit PIXI.js
- Handtracking mit MediaPipe
- Eyetracking mit WebGazer
- Interaktive Steuerung (Slider, Farben, Effekte)
2. Vorbereitung & Setup
Bevor wir mit dem eigentlichen Code starten, binden wir alle notwendigen Bibliotheken ein. Diese sorgen für Hand- und Eyetracking sowie für das visuelle Rendering unserer Partikel:
Eingesetzte Libraries:
- Pixi.js: Partikelsystem / 2D-Rendering
- Victor.js: Vektorberechnung (Bewegung)
- MediaPipe Hands: Handtracking
- WebGazer.js: Eyetracking per Webcam
<!-- MediaPipe Hands: Handtracking -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>
<!-- WebGazer: Eyetracking -->
<script src="https://webgazer.cs.brown.edu/webgazer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js"></script>
<!-- Pixi.js: Grafik-Rendering & Victor.js: Vektormathematik -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/7.2.4/pixi.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/victor/1.1.0/victor.min.js"></script>
3. UI-Elemente | Oberfläche
Für eine vollständige Benutzeroberfläche wurden UI-Elemente mit passenden Farbakzenten entwickelt. Diese umfassen:
3.1. Webcambild
Ein Webcam-Bild wurde integriert, damit der Benutzer die Handtracking- und Eyetracking-Daten in Echtzeit selbst überwachen und nachvollziehen kann. Das Webcambild wurde mit einem Overlay bewusst abgedunkelt, damit die Trackingpunkte noch mehr herausstechen können.
Html:
<video id="webcam" autoplay playsinline></video> <!-- Videoelement für Webcam, öffnet automatisch und direkt-->
<!-- Unser Punkt der zeigt wo wir hingucken -->
<div id="gaze-pointer" style="display: none; position: absolute; width: 20px; height: 20px; background: #36f9c7; border-radius: 50%; transform: translate(-50%, -50%); box-shadow: 0 0 20px lime; z-index: 9999; pointer-events: none;"></div>
<canvas id="overlay"></canvas>
Css:
#webcam, #dark-overlay, #overlay { /* positionierung von webcam und overlays */
position: absolute;
top: 10px;
left: 10px;
width: 200px;
height: 150px;
border-radius: 10px;
3.2. Partikel und Flowfield
Wir erzeugen etwa 30.000 Partikel, die sich im Flow bewegen, aufeinander reagieren und an den Bildschirmkanten abprallen.
class Walker {
constructor(texture, size, radius) {
this.vector = new Victor(Math.random() * 2 - 1, Math.random() * 2 - 1);
this.sprite = new PIXI.Sprite(texture);
this.sprite.anchor.set(0.5);
this.sprite.scale.set(0);
}
}
const PARTICLE_COUNT = 30000; //Anzahl der Partikel
const GRID_RESOLUTION = 30; //Rasterweite für Partikelbewegung
Ein Flowfield ist ein Gitter aus Vektoren, das eine simulierte Strömung repräsentiert. Diese Strömung wird in jedem Frame neu berechnet damit sich unsere Partikelchen, oder auch unsere «Walker», natürlich bewegen können.
move(gridVector, delta) { //berechnet jede bewegung pro frame neu
this.vectorWeight += 0.05 * delta;
if(this.vectorWeight > 1) this.vectorWeight = 1;
this.speed -= 0.06 * delta * window.app.speedModifier;
if(this.speed <= 0.06) this.speed = 0
this.vector.mix(gridVector, 0.7 * this.vectorWeight).normalize()
let newX = this.sprite.x + this.vector.x * this.speed;
let newY = this.sprite.y + this.vector.y * this.speed;
const TL = new Victor(this.radius, this.radius);
const BR = new Victor(
this.size.x * (this.size.grid + this.size.gutter) - this.radius,
this.size.y * (this.size.grid + this.size.gutter) - this.radius
)
if(newX < TL.x || newX > BR.x ) this.vector.invertX();
if(newY < TL.y || newY > BR.y ) this.vector.invertY();
if(newX < TL.x) this.sprite.x = TL.x;
if(newX > BR.x) this.sprite.x = BR.x;
if(newY < TL.y) this.sprite.y = TL.y;
if(newY > BR.y) this.sprite.y = BR.y;
this.sprite.x += (this.vector.x * delta) * this.speed;
this.sprite.y += (this.vector.y * delta) * this.speed;
if(this.speed > 4 && Math.random() > 0.99) this.sparkleCount = 1;
this.sparkleCount -= 0.1;
if (this.sparkleCount < 0) this.sparkleCount = 0;
3.3. Eyetracking-Toggle
Der Toggle wurde passend zur Pupillenfarbe im Eyetracking-Modus und zur Farbe der Regler gestaltet. Beim Aktivieren leuchtet er auf und ist unten links auf der Benutzeroberfläche positioniert.
<div class="toggle-wrap">
<input class="toggle-input" id="toggle" type="checkbox" />
<label class="toggle" for="toggle">
<div class="track">
<div class="bg-layer"></div>
<div class="grid-layer"></div>
<div class="highlight"></div>
</div>
<div class="thumb">
<div class="ring"></div>
<div class="core">
<div class="icon">
<div class="wave"></div>
<div class="pulse"></div>
</div>
</div>
</div>
<div class="gesture"></div>
<div class="feedback">
<div class="ripple"></div>
<div class="progress"></div>
</div>
<div class="status">
<div class="indicator">
<div class="dot"></div>
<div class="text"></div>
</div>
</div>
</label>
</div>

3.4. Handtracking mit MediaPipe Hands
MediaPipe Hands ist eine von Google entwickelte Lösung für präzises, kamerabasiertes Handtracking in Echtzeit. Die Technologie verwendet neuronale Netze, um Hände im Videobild zu erkennen und insgesamt 21 Schlüsselstellen auf der Hand zu bestimmen.
Erst wird die Hand in der Kameraansicht lokalisiert, dann werden für die gefundene Region die Koordinaten geschätzt. Diese Punkte können genutzt werden, um Gesten, Bewegungen oder bestimmte Handhaltungen (bei uns wichtig: die Faust) zu erkennen.
Wir benutzen also MediaPipe Hands, um Fingerpositionen live auf ein Canvas zu zeichnen. Wenn die Faust erkannt wird, entsteht ein spezieller Partikeleffekt – eine Explosion.
const hands = new Hands({
locateFile: file => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
});
hands.setOptions({
maxNumHands: 1,
modelComplexity: 0,
minDetectionConfidence: 0.7,
minTrackingConfidence: 0.7
});
hands.onResults(results => {
// Canvas pro Frame löschen
resizeCanvas();
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
const landmarks = results.multiHandLandmarks[0];
};
- Wenn eine Hand erkannt wird, zeichnen wir sie.
- Bestimmte Bewegungen (z.B. die Faust) lösen Partikeleffekte aus.
Wichtig für das Zeichnen
const connections = [
[0, 1], [1, 2], [2, 3], [3, 4],
[5, 6], [6, 7], [7, 8],
[9,10], [10,11], [11,12],
[13,14], [14,15], [15,16],
[17,18], [18,19], [19,20],
[0, 5], [5, 9], [9, 13], [13, 17], [17, 0]
];
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
connections.forEach(([a, b]) => {
const p1 = landmarks[a];
const p2 = landmarks[b];
ctx.beginPath();
ctx.moveTo(p1.x * canvas.width, p1.y * canvas.height);
ctx.lineTo(p2.x * canvas.width, p2.y * canvas.height);
ctx.stroke();
});
Faust-Erkennung
const fingertips = [8, 12, 16, 20].map(i => landmarks[i]);
const avg = fingertips.reduce((acc, p) => {
acc.x += p.x;
acc.y += p.y;
return acc;
}, { x: 0, y: 0 });
avg.x /= 4;
avg.y /= 4;
const spread = fingertips.reduce((acc, p) => {
return acc + Math.hypot(p.x - avg.x, p.y - avg.y);
}, 0) / 4;
const isFist = spread < 0.04;
const indexFingerTip = landmarks[8];
const wrist = landmarks[0];
3.5. Eyetracking mit Webgazer
WebGazer ist eine JavaScript-basierte Bibliothek, die Eye-Tracking ohne zusätzliche Hardware ermöglicht. WebGazer nutzt ganz einfach die normale Webcam des Nutzers. Dabei werden Gesichts- und Augenbereiche erkannt, und aus den Bewegungen der Pupillen wird abgeschätzt, wohin der Nutzer auf dem Bildschirm blickt.
Die Positionsschätzung basiert auf maschinellem Lernen und Regressionsmodellen (z.B. Ridge Regression). WebGazer eignet sich gut für einfache interaktive Anwendungen oder Demos, erreicht aber noch nicht die Präzision professioneller Eye-Tracking-Systeme, für unser Projekt reicht das aber erstmal aus.
let webgazerRunning = false;
let faceMesh;
let cameraGaze;
const gazePointer = document.getElementById('gaze-pointer');
function startEyeTracking() {
if (webgazerRunning) return;
webgazerRunning = true;
gazePointer.style.display = 'block';
webgazer.setGazeListener((data, elapsedTime) => {
if (data == null) return;
// Pointer bewegen
gazePointer.style.left = `${data.x}px`;
gazePointer.style.top = `${data.y}px`;
// Partikel erzeugen
window.app.cursorPoints.push({
position: new Victor(data.x, data.y),
direction: new Victor(0, 0),
distance: 10,
explode: false
});
});
Explosion durch Blinzeln
Im startEyeTracking()
-Block wird MediaPipe FaceMesh
genutzt, um Augenlandmarken zu erkennen, insbesondere:
const leftEyeTop = landmarks[159];
const leftEyeBottom = landmarks[145];
const rightEyeTop = landmarks[386];
const rightEyeBottom = landmarks[374];
Mit diesen Punkten wird die vertikale Höhe beider Augen berechnet:
const leftEyeDist = Math.hypot(
(leftEyeTop.x - leftEyeBottom.x),
(leftEyeTop.y - leftEyeBottom.y)
);
const rightEyeDist = Math.hypot(
(rightEyeTop.x - rightEyeBottom.x),
(rightEyeTop.y - rightEyeBottom.y)
);
Dann prüft man mit: const isBlink = (leftEyeDist + rightEyeDist) / 2 < 0.015;
Wenn die durchschnittliche Augenöffnung unter 0.015 fällt , dann wird davon ausgegangen, dass man blinzelt und es geschieht eine Explosion:
Wenn isBlink === true
:
for (let i = 0; i < 15; i++) {
window.app.cursorPoints.push({
position: new Victor(explosionX, explosionY),
direction: new Victor(Math.random() * 2 - 1, Math.random() * 2 - 1),
distance: 50,
explode: true
});
}
Es werden 15 Partikel-Explosionen an der Position des gazePointer
erzeugt, also dort, wo man hinschaut.
Darstellung der Gesichtslandmarks
Um den Erkennungsprozess (z. B. für Blinzeln) besser nachvollziehbar und ästhetischer zu gestalten, wird im Eye-Tracking-Modus ein blaues Gesicht im Overlay dargestellt.
Die MediaPipe FaceMesh-API erkennt dabei Landmark-Punkte pro Gesicht.

Das blaue Gesicht entsteht im Code durch folgende Befehle in startEyeTracking()
:
drawConnectors(faceCtx, landmarks, FACEMESH_TESSELATION, { color: '#36f9c7', lineWidth: 0.5 });
drawLandmarks(faceCtx, landmarks, { color: 'blue', radius: 0.3 });
drawConnectors(...)
: Verbindet bestimmte Landmark-Punkte mit Linien (helles Cyan).drawLandmarks(...)
: Zeichnet kleine, blaue Punkte auf alle 468 Gesichtslandmarks.
4. Container
Über HTML-Elemente wie Slider und Dropdowns können Nutzer die Eigenschaften der Partikel selbst verändern
- Geschwindigkeit: Wie schnell die Partikel von der Oberfläche verschwinden
- Partikelgröße: Radius der Partikel 1-20px
- Partikelanzahl: 1000-50000 Partikel
- Farbschema: Auswahl einer beliebigen Farbe

Partikelgröße
Durch den Slider Partikelgröße kann der Benutzer den Radius (1-20px) der Partikel regulieren.
Kleiner Radius:

Großer Radius:

Der Codeabschnitt, der die Partikelgröße bestimmt, befindet sich JavaScript-Teil der Klasse App
und der Funktion createWalkers()
const radius = this.particleRadius;
---------------------------------------------------
Zugehörige Zuweisung:
sizeRange.addEventListener("input", () => {
this.particleRadius = parseInt(sizeRange.value);
this.createWalkers();
});
Der Slider mit der ID
sizeRange
regelt die Partikelgröße.Der gewählte Wert wird der Variable
particleRadius
zugewiesenDann wird
createWalkers()
aufgerufen, um die Partikel mit der neuen Größe neu zu generieren
In createWalkers()
wird radius
dann genutzt, um die Grafikgröße der Partikel zu zeichnen mit circleGraphic.drawCircle
Partikelanzahl
Mit dem Slider Anzahl Partikel kann der Nutzer zwischen 1.000 und 50.000 Partikeln wählen. Im gezeigten Beispiel ist der Regler auf das Minimum von 1.000 Partikeln eingestellt.

<label for="countRange">Anzahl Partikel</label><br>
<div class="slider-container">
<input type="range" id="countRange" min="1000" max="50000" step="1000" value="30000" class="range">
<div class="thumb-value" id="thumbValueCount">30000</div>
</div>
Im JavaScript-Teil bestimmt window.app.walkerTotal
, wie viele Partikel erstellt werden. createWalkers()
rendert sie neu.
const countRange = document.getElementById("countRange");
countRange.addEventListener("input", () => {
window.app.walkerTotal = parseInt(countRange.value); // Anzahl Partikel wird neu gesetzt
window.app.createWalkers(); // Neue Partikel erzeugen mit aktualisierter Anzahl
});
4.1. Range Slider
Die Range Slider sind rechts oben in einem Container platziert. Diese spielen eine große Rolle bei der interaktiven Steuerung der Partikel, sei es zur Steuerung der Anzahl, der Größe oder der Geschwindigkeit. Verschiebt man den Regler so werden in der Mitte die genaue Anzahl der Partikel angezeigt.

<label for="Range">BeispielSlider</label><br>
<div class="slider-container">
<input type="range" id="Range" min="0.1" max="3" step="0.1" value="1" class="range">
<div class="thumb-value" id="thumbValueSpeed">1</div>
</div><br><br>
Get Slider4.2. Interaktive Farbpalette
Die interaktive Farbpalette ist im Container mit den Slidern integriert. Über sie kann der Nutzer flexibel die Farbkombination der Partikel steuern und entscheiden, ob diese einfarbig oder mehrfarbig dargestellt werden sollen.
Html:
<div class="header-row"> <!-- Überschrift für Farbauswahl -->
<span class="header-label">Farben auswählen</span>
</div>
<div class="slider-wrapper"> <!-- Slider für Farbauswahl mit Color picker und checkbox -->
<div class="slider-container">
<input type="range" min="1" max="5" step="1" value="2" class="range" id="sliderColors">
<div class="thumb-value" id="thumbValueColors">2</div>
</div>
<div id="colorPickers" class="color-picker-container"></div>
<div class="checkbox-row">
<label class="checkbox-container">
<input type="checkbox" id="buntCheckbox" checked>
<span class="checkbox-label">Bunt</span>
</label>
</div>
</div>
Javascript:
function getCurrentParticleColor() { //Entscheidet on regenbogen oder selber entschiedene farben
if (buntCheckbox.checked) {
const hue = (window.app.hueCount % 360);
return hslToHex(hue, 100, 50);
} else {
const randomColor = customColors[Math.floor(Math.random() * customColors.length)];
return randomColor;
}
}
function updateColorPickers() { //damit farbe auswählen bereich funktioniert aktualisieren wir die color picker jedesmal
colorPickersContainer.innerHTML = '';
const count = parseInt(colorSlider.value);
thumbValueColors.textContent = count;
thumbValueColors.style.display = 'block';
const oldColors = [...customColors];
customColors = [];
for (let i = 0; i < count; i++) {
if (oldColors[i]) {
customColors.push(oldColors[i]);
} else {
customColors.push(getRandomColor());
}
}
Farbschema

Das Ziel war Cyan neonleuchtende kleine Akzente reinzusetzen mit der Hexfarbe #36f9c7. Diese wurde in nahezu allen Elementen benutzt, um ein kohärentes Erscheinungsbild zu gewährleisten.
Steuerbare Partikelfarben
Partikelfarben steuerbar per Startoption, Checkbox und Farbschieberegler im Container
- User kann mit einem Slider bestimmen, wie viele Farben es gibt
- Standardmäßig ist eine "Bunt"-Checkbox aktiviert (= zufällige Regenbogenfarben)
- Wenn der User eine Farbe auswählt, soll die Checkbox automatisch deaktiviert werden (= eigene Farbauswahl aktiv)
- Wenn Checkbox aktiv → werden Partikel bunt, ansonsten nach den gewählten Farben.
Startfarbe der Partikel

Im Anfangszustand sind die Partikel bunt, die Farben gehen weich ineinander über und erzeugen eine fließende Mischung. Standardmäßig ist der Checkbutton bei "Bunt" aktiviert.
Auswählbare Farbe durch Benutzer

Wird die Checkbox deaktiviert, ein Regler bewegt oder ein Farbkreis angeklickt, wird die bunte Standardfarbe deaktiviert. Der Nutzer kann anschließend eine individuelle Farbe auswählen: Entweder eine beliebige Farbe aus der Farbpalette oder eine Kombination aus 1 bis 5 frei wählbaren Farben. Die Anzahl der verwendeten Farben kann dabei über den separaten Regler "Farben auswählen" bestimmt werden.

Nachdem der Nutzer seine Farben ausgewählt hat, werden diese individuell in einem „Konfetti-Effekt“ dargestellt.

Fazit
Durch dieses Projekt haben wir nicht nur den Umgang mit Hand-, Gesichts- und Augentracking gelernt, sondern auch ein besseres Verständnis für abstrakte, interaktive Programmierung entwickelt. Besonders spannend war es, eine intuitive Benutzeroberfläche zu gestalten, bei der Nutzer selbst entscheiden können, wie ihre Partikel aussehen in Farbe, Größe oder Verhalten.