Mit einem webbasierten 3D Digital Twin kann jeder mit einem Browser den Live-Zustand einer Maschine, einer Linie oder eines ganzen Werks sehen, ohne Software zu installieren. Diese Anleitung zeigt, wie Sie ihn sauber aufbauen: ein glTF-Modell vorbereiten, ein Vite-Projekt aufsetzen, MQTT-Topics über eine Konfigurationsdatei an Teile des Modells binden, Details per Klick einblenden und das Ergebnis als statische Website deployen. Die Daten kommen über Secure WebSocket von broker.connectmq.com, nach demselben Ansatz wie die 3D-Fabrik-Demo von ConnectMQ.
Architektur eines webbasierten 3D Digital Twins
Bevor Sie Code schreiben, lohnt sich ein Blick auf das Gesamtsystem. Ein Browser-Zwilling ist im Wesentlichen eine Frontend-Anwendung; die schwere Arbeit des Datentransports übernimmt der Broker.
| Schicht | Aufgabe | Technologie in dieser Anleitung |
|---|---|---|
| Geräte und Gateways | Telemetrie und Zustand veröffentlichen | Beliebiger MQTT-Client (SPS-Gateway, ESP32, Python) |
| Broker | Nachrichten routen, Retained-Zustand halten | Verwalteter MQTT-Broker, WSS auf Port 8084 |
| Modell | Geometrie, benannte Teile, Materialien | glTF-Binärformat (.glb) |
| Binding-Konfiguration | Welches Topic-Feld welches Teil wie steuert | Ein JavaScript-Konfigurationsmodul |
| Runtime | Modell laden, abonnieren, animieren, interagieren | Three.js und MQTT.js in einer Vite-App |
Der Kerngedanke ist die Binding-Konfiguration. Statt für jede Maschine eigenen Code zu schreiben, schreiben Sie eine Handvoll generischer Verhaltensweisen (Hitzeglühen, Füllstand, Rotation, Statusfarbe) und eine Liste, die Topics mit Modellteilen verbindet. Eine neue Pumpe bedeutet dann eine zusätzliche Konfigurationszeile und ein benanntes Mesh, ohne die Runtime anzufassen. Wenn das Konzept neu für Sie ist, erklärt der Überblick über Digital-Twin-Technologie, wie sich visuelle Zwillinge zu Simulations- und Analyse-Zwillingen verhalten.
Schritt 1: Assets und Topics planen
Eine typische Fabrikszene wie in der oben erwähnten Demo enthält eine CNC-Maschine, einen Roboterarm, einen Ofen, einen Tank, ein Förderband und eine Pumpe. Wir nehmen vier davon als Beispiel. Schreiben Sie den Plan zuerst auf; er wird zu Ihrer Konfigurationsdatei.
| Asset | Topic (unter Ihrem Präfix) | Feld | Visuelles Verhalten |
|---|---|---|---|
| Ofen | furnace1/telemetry |
temperature (20 bis 900) |
Emissives Glühen von dunkel bis orange |
| Tank | tank1/telemetry |
level (0 bis 1) |
Flüssigkeits-Mesh skaliert auf Y |
| Pumpe | pump1/telemetry |
rpm (0 bis 3000) |
Laufrad dreht sich |
| CNC-Maschine | cnc1/state |
state |
Signalleuchtenfarbe: läuft, im Leerlauf, Alarm |
Für die Demo verwenden wir das Präfix connectmq/demo/factory-7f3a; ersetzen Sie das Suffix durch eine eigene Zufallszeichenfolge. In der Produktion würden Sie einen hierarchischen Namespace wie acme/plant1/area2/line1/furnace1 verwenden; wie Sie ihn entwerfen, erklärt unser Artikel zu MQTT für Digital Twins. Das Leitbild dafür ist der Unified Namespace (UNS): Die ISA-95-Hierarchie Enterprise → Site → Area → Line → Cell wird zur Topic-Struktur auf dem MQTT-Broker, etwa acme/werk-stuttgart/montage/linie-1/zelle-3/temperatur. Der Zwilling setzt dann VITE_TOPIC_PREFIX auf den Teilbaum seiner Linie und teilt sich die Daten mit MES, Dashboards und Historian. Mehr dazu in Sparkplug B und Unified Namespace.
Schritt 2: Das 3D-Modell vorbereiten
Egal aus welchem Werkzeug Ihr Modell stammt (Blender, ein CAD-Export, ein gekauftes Asset), machen Sie es vor dem Export zwillingstauglich:
- Geben Sie jedem gebundenen Teil einen eindeutigen, sprechenden Namen, zum Beispiel
Furnace,Tank_Liquid,Pump_Impeller,CNC_Beacon. Namen bleiben beim glTF-Export erhalten, und über sie findet die Runtime die Teile. - Trennen Sie bewegliche Teile in eigene Objekte mit dem Pivot im Rotationszentrum. Ein Laufrad, das mit dem Pumpengehäuse verschmolzen ist, kann sich nicht eigenständig drehen.
- Geben Sie der Flüssigkeit eine Einheitshöhe mit dem Ursprung am Boden, damit die Skalierung auf Y von unten nach oben füllt.
- Reduzieren Sie Polygone bei allem, was visuell nicht wichtig ist. CAD-Exporte haben oft Millionen von Dreiecken; ein Browser-Zwilling sollte in der Regel deutlich darunter bleiben.
- Exportieren Sie als glTF-Binärdatei (
.glb) mit aktiviertem „Apply modifiers“. Optional komprimieren Sie mit Draco oder Meshopt für schnelleres Laden.
Der Code unten enthält einen Fallback, der einfache Boxen mit denselben Namen baut, falls kein Modell gefunden wird. So können Sie auch ohne Modell mitmachen.
Schritt 3: Das Projekt anlegen
npm create vite@latest web-twin -- --template vanilla
cd web-twin
npm install three mqtt
mkdir -p public/models # factory.glb hier ablegen
Die Runtime unten wartet auf oberster Modulebene auf das Modell. Weisen Sie Vite deshalb an, moderne Browser anzusprechen, indem Sie vite.config.js anlegen:
export default { build: { target: "es2022" } };
Legen Sie eine .env-Datei für Einstellungen an, die sich zwischen Umgebungen unterscheiden. Vite stellt Variablen mit dem Präfix VITE_ im Browser bereit:
VITE_MQTT_URL=wss://broker.connectmq.com:8084/mqtt
VITE_TOPIC_PREFIX=connectmq/demo/factory-7f3a
Schritt 4: Die Binding-Konfiguration schreiben
Legen Sie src/bindings.js an. Jeder Eintrag legt fest, welches Topic und Feld gelesen wird, welches Modellteil sich ändert und welches Verhalten angewendet wird.
export const PREFIX = import.meta.env.VITE_TOPIC_PREFIX;
export const bindings = [
{ topic: "furnace1/telemetry", field: "temperature", node: "Furnace", kind: "heat", min: 20, max: 900 },
{ topic: "tank1/telemetry", field: "level", node: "Tank_Liquid", kind: "fill", min: 0, max: 1 },
{ topic: "pump1/telemetry", field: "rpm", node: "Pump_Impeller", kind: "spin", axis: "z" },
{ topic: "cnc1/state", field: "state", node: "CNC_Beacon", kind: "status" },
];
export const statusColors = {
RUNNING: 0x2ecc71,
IDLE: 0xf1c40f,
ALARM: 0xe74c3c,
OFFLINE: 0x7f8c8d,
};
Schritt 5: Die Zwillings-Runtime
Ersetzen Sie src/main.js durch die folgende Runtime. Sie lädt das Modell (oder baut Platzhalter), klont Materialien, damit Teile unabhängig voneinander die Farbe wechseln können, verbindet sich mit MQTT, leitet Nachrichten über die Bindings weiter und animiert alles in einer einzigen Render-Schleife.
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import mqtt from "mqtt";
import { PREFIX, bindings, statusColors } from "./bindings.js";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x182030);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 200);
camera.position.set(10, 8, 12);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.style.margin = "0";
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.3));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(8, 15, 6);
scene.add(sun, new THREE.GridHelper(30, 30, 0x4a5568, 0x2d3748));
const panel = Object.assign(document.createElement("pre"), {
style: "position:absolute;top:10px;left:10px;margin:0;padding:10px;color:#fff;" +
"background:rgba(0,0,0,.6);border-radius:6px;font:13px monospace;max-width:320px",
textContent: "Loading model...",
});
document.body.appendChild(panel);
// ----- Modell laden mit Platzhalter-Fallback -----
function buildPlaceholders() {
const group = new THREE.Group();
const add = (name, geo, color, pos) => {
const m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color }));
m.name = name; m.position.set(...pos); group.add(m); return m;
};
add("Furnace", new THREE.BoxGeometry(2, 2, 2), 0x553322, [-4, 1, 0]);
const liquid = add("Tank_Liquid", new THREE.CylinderGeometry(0.9, 0.9, 1, 32), 0x2f7fd8, [0, 0, 0]);
liquid.geometry.translate(0, 0.5, 0); // Ursprung am Boden
add("Pump_Impeller", new THREE.BoxGeometry(0.2, 1.4, 0.2), 0xcccccc, [3, 1, 0]);
add("CNC_Beacon", new THREE.SphereGeometry(0.3, 24, 16), 0x7f8c8d, [6, 2.4, 0]);
add("CNC_Body", new THREE.BoxGeometry(2, 2, 1.5), 0x8899aa, [6, 1, 0]);
return group;
}
async function loadModel() {
try {
const gltf = await new GLTFLoader().loadAsync("/models/factory.glb");
return gltf.scene;
} catch {
console.warn("factory.glb not found, using placeholders");
return buildPlaceholders();
}
}
const root = await loadModel();
scene.add(root);
// ----- Bindings zu Objekten auflösen und ihnen eigene Materialien geben -----
const runtime = bindings.map((b) => {
const obj = root.getObjectByName(b.node);
if (!obj) console.warn(`Binding target "${b.node}" not found in model`);
obj?.traverse((o) => { if (o.isMesh) o.material = o.material.clone(); });
return { ...b, obj, target: b.min ?? 0, current: b.min ?? 0, last: null, at: 0 };
});
const byTopic = new Map();
for (const r of runtime) {
const full = `${PREFIX}/${r.topic}`;
if (!byTopic.has(full)) byTopic.set(full, []);
byTopic.get(full).push(r);
}
// ----- MQTT -----
const client = mqtt.connect(import.meta.env.VITE_MQTT_URL, {
clientId: import.meta.env.VITE_MQTT_CLIENT_ID || "web-twin-" + crypto.randomUUID().slice(0, 8),
username: import.meta.env.VITE_MQTT_USERNAME || undefined,
password: import.meta.env.VITE_MQTT_PASSWORD || undefined,
protocolVersion: 5,
reconnectPeriod: 2000,
});
let connected = false;
client.on("connect", () => { connected = true; client.subscribe(`${PREFIX}/#`, { qos: 0 }); });
client.on("close", () => (connected = false));
client.on("message", (topic, payload) => {
const targets = byTopic.get(topic);
if (!targets) return;
let data;
try { data = JSON.parse(new TextDecoder().decode(payload)); } catch { return; }
for (const r of targets) {
if (!(r.field in data)) continue;
r.target = data[r.field];
r.last = data;
r.at = Date.now();
}
});
// ----- Verhaltensweisen -----
const tmp = new THREE.Color();
const behaviours = {
heat(r, k) {
r.current += (r.target - r.current) * k;
const t = THREE.MathUtils.clamp((r.current - r.min) / (r.max - r.min), 0, 1);
r.obj.traverse((o) => {
if (!o.isMesh) return;
o.material.emissive.setRGB(1, 0.35, 0.05);
o.material.emissiveIntensity = t * 1.5;
});
},
fill(r, k) {
r.current += (r.target - r.current) * k;
r.obj.scale.y = Math.max(THREE.MathUtils.clamp(r.current, 0, 1) * 2.8, 0.001);
},
spin(r, k, dt) {
r.current += (r.target - r.current) * k;
r.obj.rotation[r.axis] += (r.current / 60) * Math.PI * 2 * dt * 0.05;
},
status(r) {
const stale = Date.now() - r.at > 10000;
tmp.setHex(stale ? statusColors.OFFLINE : statusColors[r.target] ?? statusColors.OFFLINE);
r.obj.traverse((o) => {
if (!o.isMesh) return;
o.material.color.copy(tmp);
o.material.emissive.copy(tmp);
o.material.emissiveIntensity = 0.6;
});
},
};
// ----- Per Klick inspizieren -----
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let selected = null;
renderer.domElement.addEventListener("pointerdown", (e) => {
pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(root.children, true)[0];
selected = hit ? runtime.find((r) => r.obj && (r.obj === hit.object || r.obj.getObjectById(hit.object.id))) : null;
});
// ----- Render-Schleife -----
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
const k = 1 - Math.exp(-dt * 4);
for (const r of runtime) if (r.obj) behaviours[r.kind](r, k, dt);
const head = connected ? "MQTT connected" : "MQTT disconnected";
panel.textContent = selected
? `${head}\n\n${selected.node}\n${JSON.stringify(selected.last, null, 2)}`
: `${head}\nClick a machine for details`;
controls.update();
renderer.render(scene, camera);
});
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
Starten Sie npm run dev und öffnen Sie die lokale URL. Speisen Sie dann aus einem Terminal Daten ein:
P=connectmq/demo/factory-7f3a
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/furnace1/telemetry" -m '{"temperature":640}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/tank1/telemetry" -m '{"level":0.7}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/pump1/telemetry" -m '{"rpm":1800}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/cnc1/state" -m '{"state":"RUNNING"}'
Das Flag -r veröffentlicht Retained Messages. Auch wenn Sie die Seite später neu laden, zeigt der Zwilling sofort die letzten bekannten Werte statt einer leeren Fabrik. Dieses eine Flag macht den Unterschied zwischen einem Zwilling, der erst „warmlaufen“ muss, und einem, der schon beim ersten Rendern korrekt ist; die Abwägungen dazu finden Sie unter Retained MQTT Messages für Digital Twins.
Designentscheidungen, die sich auszahlen
Konfiguration statt Code. Bekommt das Werk einen zweiten Ofen, fügen Sie einen Eintrag in bindings.js und ein Mesh namens Furnace_2 hinzu. Die Runtime bleibt unverändert, was es später auch leicht macht, die Konfiguration aus einer Asset-Datenbank zu erzeugen.
Eigene Materialien pro gebundenem Teil. glTF-Exporter teilen oft ein Material zwischen vielen Meshes. Ohne den clone()-Schritt würde das Glühen eines Ofens jedes Objekt mit diesem Material zum Glühen bringen.
Status, der veralten kann. Das Verhalten status wird grau, wenn zehn Sekunden lang keine Zustandsnachricht eintrifft. Ein Zwilling darf niemals „läuft“ für eine Maschine anzeigen, deren Gateway vor einer Stunde ausgefallen ist.
Ein einziges Abonnement. Ein einzelnes Wildcard-Abonnement unter dem Präfix hält den Client deutlich unter dem Limit des öffentlichen MQTT-Brokers von 10 Abonnements pro Client, und die Router-Map übernimmt die Verteilung pro Topic lokal.
Einen tieferen Blick auf die Mapping-Schicht, einschließlich Morph Targets und Animationsclips, bietet IoT-Daten an ein 3D-Modell binden. Um KPI-Karten und Diagramme neben die 3D-Ansicht zu stellen, machen Sie mit dem Tutorial zum Digital-Twin-Dashboard weiter.
Schritt 6: Build und Deployment
Der Zwilling ist eine statische Website, das Hosting ist also einfach:
npm run build # erzeugt dist/
npx serve dist # lokale Prüfung des Produktions-Builds
Laden Sie dist/ auf einen beliebigen statischen Host oder in einen Object Storage mit CDN hoch. Gehen Sie vorher diese Checkliste durch:
- Richten Sie
VITE_MQTT_URLauf Ihren privaten Broker und setzen Sie ein Produktions-Topic-Präfix. - Liefern Sie niemals Geräte-Zugangsdaten im Bundle aus. Der Browser erhält eigene Zugangsdaten, und die Seite liegt hinter Ihrem Login.
- Stellen Sie die Seite über HTTPS bereit; Browser blockieren
ws://-Verbindungen von HTTPS-Seiten, weshalb der Zwillingwss://auf Port 8084 nutzt. - Komprimieren Sie das Modell und prüfen Sie die Ladezeit bei langsamer Verbindung.
- Testen Sie, was passiert, wenn der Broker nicht erreichbar ist: Das Panel sollte „MQTT disconnected“ anzeigen und die Status sollten grau werden.
Den Zwilling auf Ihren eigenen Broker umziehen
Der öffentliche MQTT-Broker ist ideal zum Entwickeln und für Demos, aber alles darauf ist für jeden sichtbar; Produktivdaten gehören nicht dorthin. Für ein echtes Werk bietet Ihnen ein verwalteter MQTT-Broker Zugangsdaten, TLS und Isolation, ohne dass Sie Server betreiben. Mit ConnectMQ erstellen Sie im Portal einen Broker, kopieren den generierten Benutzernamen, das Passwort und die Client ID und fügen VITE_MQTT_USERNAME, VITE_MQTT_PASSWORD, VITE_MQTT_CLIENT_ID sowie die URL zu Ihrer Produktionsumgebung hinzu. Der Broker erkennt außerdem Topic-Hierarchien im ISA-95-Stil und kann Sparkplug-B-Daten in lesbare ISA-95-Topics republizieren. So erhält ein Web-Zwilling saubere, JSON-freundliche Topics zum Binden, selbst wenn in der Fertigung Sparkplug gesprochen wird.
Starten Sie mit ConnectMQ
Am schnellsten sehen Sie ein fertiges Ergebnis in der 3D-Digital-Twin-Webdemo von ConnectMQ, die Live-Daten über Secure WebSocket in eine Fabrikszene streamt. Wenn Sie bereit sind, Ihren eigenen webbasierten 3D Digital Twin zu betreiben, registrieren Sie sich unter app.connectmq.com für den kostenlosen Serverless-Plan, ohne Kreditkarte, und richten Sie VITE_MQTT_URL auf Ihren neuen Broker. Sie wählen zuerst noch zwischen Hosting-Modellen? Unser Leitfaden zum Cloud-MQTT-Broker vergleicht die Optionen.
Häufig gestellte Fragen
Welches 3D-Format eignet sich am besten für einen Web-Digital-Twin?
Das glTF-Binärformat (.glb) ist der Standard für das Web: kompakt, schnell zu laden und nativ von Three.js und Babylon.js unterstützt. Konvertieren Sie CAD-Formate wie STEP oder FBX nach glTF und vereinfachen Sie sie vor der Verwendung.
Wie zeigt der Zwilling direkt nach dem Laden der Seite korrekte Werte?
Geräte veröffentlichen ihren letzten Zustand als Retained Messages, sodass der Broker den letzten Wert jedes Topics ausliefert, sobald der Browser abonniert. Ohne Retained Messages bleibt der Zwilling leer, bis das nächste Update eintrifft.
Kann ich Maschinen über einen webbasierten 3D Digital Twin steuern?
Ja, indem die Seite auf Befehls-Topics veröffentlicht, zum Beispiel wenn ein Benutzer auf eine Maschine klickt. Gehen Sie mit Befehlen sorgfältig um: Fordern Sie eine Bestätigung an, verwenden Sie QoS 1 und lassen Sie das Gerät seinen tatsächlichen Zustand zurückmelden.
Funktioniert ein Browser-Zwilling auf Tablets?
Ja. WebGL läuft in modernen mobilen Browsern. Halten Sie Polygonzahlen und Texturgrößen moderat und begrenzen Sie wie im Beispiel das Pixel-Verhältnis, damit die Bildrate flüssig bleibt.



