Digitaler Zwilling

Three.js Digitaler Zwilling: Live-3D-Visualisierung mit MQTT

Bauen Sie einen browserbasierten Three.js digitalen Zwilling eines Tanks und eines Motors, der über Secure WebSocket auf Live-MQTT-Telemetrie reagiert, mit einem Node.js-Simulator, sanfter Interpolation und Tipps für den Produktivbetrieb.

Three.js digitaler Zwilling eines Tanks und eines Motors, die im Browser mit Live-MQTT-Sensordaten ihre Farbe ändern
Probieren Sie es auf Ihrem eigenen MQTT-Broker ausHolen Sie sich im Free-Tarif einen privaten, verwalteten MQTT-Broker und starten Sie mit fertigen Codebeispielen.
Kostenlos testen Codebeispiele Keine Kreditkarte erforderlich

Ein Three.js digitaler Zwilling verwandelt einen Strom von Sensornachrichten in eine 3D-Szene, die Sie drehen, zoomen und auf einen Blick verstehen können. In diesem Tutorial bauen Sie einen in einer einzigen HTML-Datei: einen Tank, dessen Flüssigkeitsstand steigt und fällt, und einen Motor, der seine Farbe mit der Temperatur ändert und sich mit der gemeldeten Drehzahl dreht, alles gesteuert über MQTT via Secure WebSocket von broker.connectmq.com. Außerdem schreiben Sie einen kleinen Simulator, damit Sie den Zwilling testen können, bevor überhaupt echte Hardware existiert.

Was ein Three.js digitaler Zwilling braucht

Ein Web-Zwilling hat drei bewegliche Teile, und ihre Trennung macht ihn wartbar:

  1. Ein Datenfeed. Geräte veröffentlichen Telemetrie auf MQTT-Topics. Der Browser abonniert mit MQTT.js über WebSocket, weil Browser keine rohen TCP-Sockets öffnen können.
  2. Ein State Store. Eingehende Nachrichten aktualisieren ein einfaches JavaScript-Objekt mit Zielwerten. Nichts im Message-Handler berührt die 3D-Szene direkt.
  3. Eine Render-Schleife. In jedem Animationsframe nähert die Szene ihre aktuellen Werte den Zielwerten an und zeichnet neu.

Diese Trennung ist wichtiger, als sie aussieht. Nachrichten können schubweise eintreffen, außerhalb des Rhythmus der 60 Bilder pro Sekunde, die der Browser rendert. Wenn Sie Meshes im MQTT-Callback verändern, bekommen Sie ruckelige Bewegungen und schwer auffindbare Race Conditions. Mit einem State Store dazwischen arbeitet der Renderer immer mit einem konsistenten Snapshot, und Sie können sanft interpolieren.

Für das Gesamtbild erklärt unser Leitfaden zur Technologie des digitalen Zwillings den Unterschied zwischen einem visuellen Zwilling wie diesem und Zwillingen, die zusätzlich simulieren oder vorhersagen.

Topic-Design für den Zwilling

Verwenden Sie ein Topic pro Anlage und einen JSON-Payload pro Nachricht. Wählen Sie ein zufälliges Suffix für das Präfix, damit Sie nicht mit anderen kollidieren, die auf dem öffentlichen MQTT-Broker testen:

Topic Payload-Beispiel Steuert in der Szene
connectmq/demo/twin-7f3a/tank1/telemetry {"level":0.62,"ts":1760600000000} Flüssigkeitshöhe (0 bis 1)
connectmq/demo/twin-7f3a/motor1/telemetry {"temperature":58.4,"rpm":1320,"ts":...} Gehäusefarbe und Lüfterdrehung
connectmq/demo/twin-7f3a/motor1/status online / offline (retained) Ausgegraut, wenn offline

Ein Topic pro Anlage hält das Browser-Abonnement einfach (eine einzige #-Wildcard unter Ihrem Präfix) und skaliert sauber auf Dutzende Anlagen. Normierte Werte wie ein Füllstand zwischen 0 und 1 halten den Szenencode zudem frei von Einheitenumrechnungen. Wenn Sie Topics für einen größeren Zwilling entwerfen, behandelt MQTT für digitale Zwillinge Hierarchie- und Payload-Konventionen ausführlicher.

Im Werk ersetzen Sie das Demo-Präfix durch einen Unified Namespace (UNS): Die ISA-95-Hierarchie aus Enterprise, Site, Area, Line und Cell wird zur Topic-Struktur auf dem MQTT-Broker, etwa acme/werk-stuttgart/montage/linie-1/zelle-3/motor1/telemetry. Der Zwilling abonniert dann einfach den Teilbaum seiner Linie, und jede weitere Anwendung, ob MES, Dashboard oder Energiemonitoring, liest dieselben Daten. Wie Sparkplug B dazu passt, zeigt Sparkplug B und Unified Namespace.

Schritt 1: Die Anlage simulieren

Für den Einstieg brauchen Sie keine Hardware. Dieses Node.js-Skript veröffentlicht einmal pro Sekunde realistisch wirkende Daten für Tank und Motor. Speichern Sie es als simulator.mjs, führen Sie npm install mqtt und dann node simulator.mjs aus.

import mqtt from "mqtt";

const PREFIX = "connectmq/demo/twin-7f3a";
const client = mqtt.connect("mqtts://broker.connectmq.com:8883", {
  clientId: "twin-sim-7f3a",
  protocolVersion: 5,
  will: { topic: `${PREFIX}/motor1/status`, payload: "offline", qos: 1, retain: true },
});

let t = 0;

client.on("connect", () => {
  console.log("Simulator connected");
  client.publish(`${PREFIX}/motor1/status`, "online", { qos: 1, retain: true });

  setInterval(() => {
    t += 1;
    const level = 0.5 + 0.4 * Math.sin(t / 20);
    const temperature = 50 + 30 * Math.sin(t / 15);
    const rpm = temperature > 75 ? 0 : Math.round(1200 + 300 * Math.sin(t / 7));

    client.publish(`${PREFIX}/tank1/telemetry`,
      JSON.stringify({ level: Number(level.toFixed(3)), ts: Date.now() }));
    client.publish(`${PREFIX}/motor1/telemetry`,
      JSON.stringify({ temperature: Number(temperature.toFixed(1)), rpm, ts: Date.now() }));
  }, 1000);
});

client.on("error", (err) => console.error("MQTT error:", err.message));

Der Motor „löst aus“ und stoppt, sobald die simulierte Temperatur 75 Grad überschreitet, was dem Zwilling etwas Interessantes zum Anzeigen gibt. Zwei Nachrichten pro Sekunde liegen deutlich innerhalb des Limits des öffentlichen MQTT-Brokers von 10 Nachrichten pro Sekunde pro Client. Prüfen Sie, ob die Daten fließen, bevor Sie 3D-Code anfassen:

mosquitto_sub -h broker.connectmq.com -p 1883 -i check-7f3a -v -t "connectmq/demo/twin-7f3a/#"

Schritt 2: Die Szene des Three.js digitalen Zwillings bauen

Speichern Sie Folgendes als index.html. Die Datei nutzt eine Import Map, um Three.js und MQTT.js als ES-Module von einem CDN zu laden, es gibt also keinen Build-Schritt. Liefern Sie sie mit einem beliebigen statischen Server aus, zum Beispiel npx serve ., und öffnen Sie die ausgegebene URL. Das direkte Öffnen der Datei von der Festplatte funktioniert in den meisten Browsern ebenfalls, aber ein lokaler Server umgeht Einschränkungen beim Laden von Modulen.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Three.js Digital Twin</title>
  <style>
    body { margin: 0; overflow: hidden; font-family: system-ui, sans-serif; background: #1d2330; }
    #hud { position: absolute; top: 12px; left: 12px; color: #fff; background: rgba(0,0,0,.55);
           padding: 8px 12px; border-radius: 6px; font-size: 14px; line-height: 1.5; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
      "mqtt": "https://unpkg.com/mqtt@5.10.1/dist/mqtt.esm.js"
    }
  }
  </script>
</head>
<body>
  <div id="hud">Connecting...</div>
  <script type="module">
    import * as THREE from "three";
    import { OrbitControls } from "three/addons/controls/OrbitControls.js";
    import mqtt from "mqtt";

    const PREFIX = "connectmq/demo/twin-7f3a";
    const hud = document.getElementById("hud");

    // ---------- Szene ----------
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x1d2330);
    const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
    camera.position.set(6, 5, 8);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.setSize(innerWidth, innerHeight);
    document.body.appendChild(renderer.domElement);
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.target.set(0, 1, 0);
    controls.enableDamping = true;

    scene.add(new THREE.HemisphereLight(0xffffff, 0x444466, 1.2));
    const sun = new THREE.DirectionalLight(0xffffff, 1.5);
    sun.position.set(5, 10, 5);
    scene.add(sun, new THREE.GridHelper(20, 20, 0x556070, 0x333a48));

    // Tank: transparente Hülle + Flüssigkeitssäule (Geometriehöhe 1, auf Y skaliert)
    const TANK_H = 3;
    const shell = new THREE.Mesh(
      new THREE.CylinderGeometry(1, 1, TANK_H, 40, 1, true),
      new THREE.MeshStandardMaterial({ color: 0x99aabb, transparent: true, opacity: 0.25, side: THREE.DoubleSide })
    );
    shell.position.set(-2, TANK_H / 2, 0);
    const liquid = new THREE.Mesh(
      new THREE.CylinderGeometry(0.95, 0.95, 1, 40),
      new THREE.MeshStandardMaterial({ color: 0x2f7fd8 })
    );
    liquid.position.x = -2;
    scene.add(shell, liquid);

    // Motor: Gehäuse + Lüfter, der sich mit der Drehzahl dreht
    const motorMat = new THREE.MeshStandardMaterial({ color: 0x44aa66 });
    const motor = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1, 1), motorMat);
    motor.position.set(2, 0.5, 0);
    const fan = new THREE.Mesh(
      new THREE.BoxGeometry(0.08, 0.9, 0.15),
      new THREE.MeshStandardMaterial({ color: 0xdddddd })
    );
    fan.position.set(2.75, 0.5, 0);
    scene.add(motor, fan);

    // ---------- State Store ----------
    const target = { level: 0, temperature: 20, rpm: 0, online: false };
    const current = { level: 0, temperature: 20, rpm: 0 };
    let lastMessageAt = 0;

    // ---------- MQTT ----------
    const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
      clientId: "twin-viewer-" + Math.random().toString(16).slice(2, 10),
      protocolVersion: 5,
      reconnectPeriod: 2000,
    });
    const decoder = new TextDecoder();

    client.on("connect", () => {
      hud.textContent = "Connected";
      client.subscribe(`${PREFIX}/#`, { qos: 0 });
    });
    client.on("reconnect", () => (hud.textContent = "Reconnecting..."));
    client.on("message", (topic, payload) => {
      const text = decoder.decode(payload);
      lastMessageAt = performance.now();
      if (topic.endsWith("/motor1/status")) { target.online = text === "online"; return; }
      let data;
      try { data = JSON.parse(text); } catch { return; }
      if (topic.endsWith("/tank1/telemetry") && typeof data.level === "number") {
        target.level = THREE.MathUtils.clamp(data.level, 0, 1);
      } else if (topic.endsWith("/motor1/telemetry")) {
        if (typeof data.temperature === "number") target.temperature = data.temperature;
        if (typeof data.rpm === "number") target.rpm = data.rpm;
      }
    });

    // ---------- Render-Schleife ----------
    const clock = new THREE.Clock();
    const cold = new THREE.Color(0x2e86de), hot = new THREE.Color(0xe74c3c), grey = new THREE.Color(0x666666);

    function animate() {
      requestAnimationFrame(animate);
      const dt = clock.getDelta();
      const k = 1 - Math.exp(-dt * 4); // bildratenunabhängiges Easing
      current.level += (target.level - current.level) * k;
      current.temperature += (target.temperature - current.temperature) * k;
      current.rpm += (target.rpm - current.rpm) * k;

      const h = Math.max(current.level * (TANK_H - 0.1), 0.001);
      liquid.scale.y = h;
      liquid.position.y = h / 2 + 0.05;

      const t01 = THREE.MathUtils.clamp((current.temperature - 20) / 70, 0, 1);
      if (target.online) motorMat.color.copy(cold).lerp(hot, t01);
      else motorMat.color.copy(grey);

      fan.rotation.x += (current.rpm / 60) * Math.PI * 2 * dt * 0.05; // zur besseren Sichtbarkeit verlangsamt

      const stale = performance.now() - lastMessageAt > 5000;
      hud.innerHTML = `Level ${(current.level * 100).toFixed(0)} %<br>` +
        `Motor ${current.temperature.toFixed(1)} C, ${Math.round(current.rpm)} rpm` +
        (stale ? "<br>No data for 5 s" : "");

      controls.update();
      renderer.render(scene, camera);
    }
    animate();

    addEventListener("resize", () => {
      camera.aspect = innerWidth / innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(innerWidth, innerHeight);
    });
  </script>
</body>
</html>

Bei laufendem Simulator steigt und fällt die Flüssigkeitssäule, der Motor wechselt beim Erwärmen von Blau zu Rot, der Lüfter läuft aus, wenn der Motor auslöst, und der Motor wird grau, wenn Sie den Simulator beenden und sein Last Will greift.

So funktionieren die Live-Bindungen

Einige Details dieser Datei lohnt es sich in jeden Three.js digitalen Zwilling zu übernehmen, den Sie bauen.

Eine Geometrie mit Einheitshöhe skalieren. Der Flüssigkeitszylinder wird mit der Höhe 1 erzeugt, sodass scale.y seiner Höhe in Szeneneinheiten entspricht. position.y auf die halbe Höhe zu setzen, hält den Boden am Tankboden verankert. Das ist günstiger und einfacher, als die Geometrie bei jeder Nachricht neu aufzubauen.

Materialien verändern, nicht ersetzen. Der Aufruf material.color.copy(...) aktualisiert das bestehende Material. Ein neues Material pro Nachricht zu erzeugen, verursacht GPU-Ressourcenlecks und Shader-Neukompilierungen.

Zu den Zielwerten hin glätten. Der Ausdruck 1 - Math.exp(-dt * 4) liefert eine bildratenunabhängige Glättung: Die Bewegung sieht bei 30 und bei 144 Bildern pro Sekunde gleich aus. Bei einer Nachricht pro Sekunde würden Rohwerte die Szene einmal pro Sekunde springen lassen.

Veraltete Daten anzeigen. Ein Zwilling, der stillschweigend einfriert, ist schlimmer als gar keiner, weil Menschen ihm vertrauen. Das HUD meldet, wenn fünf Sekunden lang keine Nachricht angekommen ist, und das Retained-Topic status zusammen mit dem Last Will des Simulators zeigt Ihnen, ob das Gerät selbst verschwunden ist. Retained Messages sorgen außerdem dafür, dass eine frisch geöffnete Seite sofort den letzten bekannten Zustand zeigt; siehe Retained Messages für digitale Zwillinge dazu, wann Sie retainen sollten und wann nicht.

Typische Stolperfallen

Symptom Wahrscheinliche Ursache Lösung
Seite lädt, HUD bleibt bei „Connecting...“ Falsche URL oder fehlender Pfad /mqtt wss://broker.connectmq.com:8084/mqtt verwenden
Verbindet sich, trennt sich dann alle paar Sekunden Doppelte Client ID über mehrere Tabs Client ID bei jedem Laden der Seite zufällig erzeugen
Schwarzer Bildschirm, Fehler zu Modulen Datei auf eine Weise geöffnet, die Modulimporte blockiert Mit npx serve . oder einem anderen statischen Server ausliefern
Werte aktualisieren sich, aber die Bewegung ruckelt Meshes werden im MQTT-Callback aktualisiert In den State Store schreiben, in der Render-Schleife animieren
Mixed-Content-Fehler auf einer HTTPS-Seite ws:// von einer https://-Seite aus Immer wss:// auf Port 8084 verwenden

Wenn überhaupt keine Nachrichten ankommen, prüfen Sie in den Entwicklertools des Browsers im Reiter Netzwerk die WebSocket-Verbindung und lesen Sie MQTT über WebSocket für eine vollständige Erklärung von Ports, Pfaden und Proxys.

Von Primitiven zu echten Modellen

Boxen und Zylinder eignen sich perfekt, um den Datenpfad zu beweisen. Der nächste Schritt ist meist ein glTF-Modell, exportiert aus Blender oder einem CAD-Werkzeug, in dem Sie Meshes nach Namen suchen und Topics an sie binden. Genau das behandelt IoT-Daten an ein 3D-Modell binden, einschließlich Materialien, Morph Targets und Animationsclips. Wenn Sie die vollständige End-to-End-Struktur eines produktiven Web-Zwillings mit konfigurationsgesteuerten Bindungen möchten, folgen Sie einen webbasierten 3D-Digital-Twin bauen. Sie bevorzugen eine andere Engine? Dasselbe MQTT-Muster funktioniert in Babylon.js.

Der Weg in den Produktivbetrieb

Der öffentliche MQTT-Broker ist für dieses Tutorial großartig, aber er ist anonym und für jeden lesbar; streamen Sie also niemals echte Werksdaten darüber. Für den Produktivbetrieb wollen Sie einen eigenen Broker mit Zugangsdaten und TLS. ConnectMQ bietet einen verwalteten MQTT-Broker mit Secure WebSocket auf Port 8084 und einem vertrauenswürdigen Let's-Encrypt-Zertifikat, sodass die Browserverbindung ohne jede Zertifikatseinrichtung funktioniert. Ergänzen Sie die Zugangsdaten in den MQTT.js-Optionen:

const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
  clientId: "YOUR_CLIENT_ID",
  username: "YOUR_USERNAME",
  password: "YOUR_PASSWORD",
  protocolVersion: 5,
});

Alles, was an einen Browser ausgeliefert wird, ist für dessen Nutzer sichtbar. Geben Sie der Viewer-Seite daher eigene Zugangsdaten, getrennt von den Gerätezugangsdaten, damit Sie sie unabhängig voneinander rotieren können, und liefern Sie die Seite nur an authentifizierte Nutzer aus. Viele Teams setzen einen schlanken Login vor die Seite und injizieren die MQTT-Zugangsdaten nach der Anmeldung.

Starten Sie mit ConnectMQ

ConnectMQ liefert eine eigene 3D-Digital-Twin-Web-Demo: eine Fabrik mit CNC-Maschine, Roboterarm, Ofen, Tank, Förderband und Pumpe, die sich live über Secure WebSocket aktualisiert und genau auf dem Muster dieses Artikels aufbaut. Um Ihren eigenen Three.js digitalen Zwilling auf einem privaten Broker zu betreiben, erstellen Sie einen kostenlosen Broker unter app.connectmq.com. Der kostenlose Serverless-Plan benötigt keine Kreditkarte und unterstützt bis zu 25 Verbindungen, genug für einige Geräte und Viewer, und der Live-Topic-Explorer im Portal hilft Ihnen, jede Bindung zu prüfen. Bis dahin können Sie auf dem öffentlichen MQTT-Broker weiter experimentieren.

Häufig gestellte Fragen

Kann sich Three.js direkt mit MQTT verbinden?

Three.js rendert nur; die Verbindung übernimmt MQTT.js auf derselben Seite. MQTT.js nutzt im Browser WebSocket, daher muss der Broker einen Endpunkt für MQTT über WebSocket bereitstellen, etwa Port 8084 mit dem Pfad /mqtt.

Wie viele Objekte kann ein digitaler Zwilling im Browser in Echtzeit aktualisieren?

Transformationen und Materialfarben zu aktualisieren ist günstig, daher sind Hunderte gebundene Objekte auf gewöhnlicher Hardware realistisch. Der übliche Engpass ist die Komplexität der Geometrie und die Anzahl der Draw Calls, nicht die Verarbeitung der MQTT-Nachrichten.

Sollte der Zwilling mit QoS 0 oder QoS 1 abonnieren?

QoS 0 ist für visuelle Telemetrie meist richtig, weil ein neuerer Wert einen verlorenen innerhalb von Sekunden ersetzt. Verwenden Sie QoS 1 für Befehle und Alarme, die nicht verpasst werden dürfen.

Brauche ich einen Backend-Server für einen Three.js digitalen Zwilling?

Für die Live-Visualisierung nicht: Der Browser spricht direkt mit dem Broker. Ein Backend brauchen Sie nur für Dinge wie historische Abfragen, Benutzerauthentifizierung oder die Ausgabe von Zugangsdaten pro Nutzer.

Verwandte Artikel

Alle Artikel →

Ihr privater MQTT-Broker ist nur wenige Klicks entfernt

Konto anlegen, Broker-Zugangsdaten erhalten und das erste Gerät verbinden — ohne Server, die installiert, gepatcht oder skaliert werden müssen.