Jumeau numérique

Jumeau numérique Three.js : visualisation 3D en direct avec MQTT

Construisez dans le navigateur un jumeau numérique Three.js d'une cuve et d'un moteur qui réagit à la télémétrie MQTT en direct via WebSocket sécurisé, avec un simulateur Node.js, une interpolation fluide et des conseils pour la production.

Jumeau numérique Three.js d'une cuve et d'un moteur changeant de couleur selon les données de capteurs MQTT en direct dans un navigateur
Essayez-le sur votre propre broker MQTTObtenez un broker MQTT privé et managé avec l’offre Free et partez d’exemples de code prêts à l’emploi.
Essayer gratuitement Exemples de code Aucune carte bancaire requise

Un jumeau numérique Three.js transforme un flux de messages de capteurs en une scène 3D que vous pouvez faire pivoter, zoomer et comprendre d'un coup d'œil. Dans ce tutoriel, vous en construirez un dans un seul fichier HTML : une cuve dont le niveau de liquide monte et descend, et un moteur qui change de couleur selon sa température et tourne à la vitesse rapportée, le tout piloté par MQTT via WebSocket sécurisé depuis broker.connectmq.com. Vous écrirez aussi un petit simulateur pour tester le jumeau avant même que le moindre matériel existe.

Ce dont un jumeau numérique Three.js a besoin

Un jumeau numérique web comporte trois éléments mobiles, et c'est le fait de les garder séparés qui le rend maintenable :

  1. Un flux de données. Les équipements publient leur télémétrie sur des topics MQTT. Le navigateur s'abonne avec MQTT.js via WebSocket, car les navigateurs ne peuvent pas ouvrir de sockets TCP bruts.
  2. Un magasin d'état. Les messages entrants mettent à jour un simple objet JavaScript de valeurs cibles. Rien dans le gestionnaire de messages ne touche directement la scène 3D.
  3. Une boucle de rendu. À chaque image d'animation, la scène fait converger progressivement ses valeurs courantes vers les cibles et se redessine.

Cette séparation compte plus qu'il n'y paraît. Les messages peuvent arriver par rafales, sans rapport avec le rythme de 60 images par seconde du navigateur. Si vous modifiez les meshes dans le callback MQTT, vous obtenez des mouvements saccadés et des conditions de concurrence difficiles à traquer. Avec un magasin d'état intermédiaire, le moteur de rendu travaille toujours sur un instantané cohérent et vous pouvez interpoler en douceur.

Pour une vue d'ensemble, notre guide de la technologie du jumeau numérique explique la différence entre un jumeau visuel comme celui-ci et les jumeaux qui simulent ou prédisent également.

Conception des topics du jumeau

Utilisez un topic par actif et un payload JSON par message. Choisissez un suffixe aléatoire pour le préfixe afin de ne pas entrer en collision avec d'autres personnes qui testent sur le serveur MQTT public gratuit :

Topic Exemple de payload Ce qu'il pilote dans la scène
connectmq/demo/twin-7f3a/tank1/telemetry {"level":0.62,"ts":1760600000000} Hauteur du liquide (0 à 1)
connectmq/demo/twin-7f3a/motor1/telemetry {"temperature":58.4,"rpm":1320,"ts":...} Couleur du corps et rotation du ventilateur
connectmq/demo/twin-7f3a/motor1/status online / offline (retenu) Passage au gris hors ligne

Un topic par actif garde l'abonnement du navigateur simple (un seul wildcard # sous votre préfixe) et s'étend proprement à des dizaines d'actifs. Des valeurs normalisées, comme un niveau entre 0 et 1, évitent aussi les conversions d'unités dans le code de la scène. Si vous concevez des topics pour un jumeau plus vaste, MQTT pour les jumeaux numériques approfondit les conventions de hiérarchie et de payload.

Étape 1 : simuler l'équipement

Vous n'avez pas besoin de matériel pour commencer. Ce script Node.js publie une fois par seconde des données réalistes pour la cuve et le moteur. Enregistrez-le sous simulator.mjs, exécutez npm install mqtt, puis node simulator.mjs.

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));

Le moteur « disjoncte » et s'arrête chaque fois que la température simulée dépasse 75 degrés, ce qui donne au jumeau quelque chose d'intéressant à montrer. Deux messages par seconde restent largement dans la limite de 10 messages par seconde et par client du serveur MQTT public gratuit. Vérifiez que les données circulent avant de toucher au moindre code 3D :

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

Étape 2 : construire la scène du jumeau numérique Three.js

Enregistrez ce qui suit sous index.html. Il utilise une import map pour charger Three.js et MQTT.js sous forme de modules ES depuis un CDN : aucune étape de build n'est nécessaire. Servez-le avec n'importe quel serveur statique, par exemple npx serve ., et ouvrez l'URL affichée. Ouvrir le fichier directement depuis le disque fonctionne aussi dans la plupart des navigateurs, mais un serveur local évite les restrictions de chargement des modules.

<!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");

    // ---------- Scene ----------
    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: transparent shell + liquid column (geometry height 1, scaled on Y)
    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: body + fan that spins with rpm
    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 loop ----------
    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); // frame-rate independent 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; // slowed for visibility

      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>

Avec le simulateur en marche, la colonne de liquide monte et descend, le moteur passe du bleu au rouge en chauffant, le ventilateur ralentit jusqu'à l'arrêt quand le moteur disjoncte, et le moteur devient gris si vous arrêtez le simulateur et que son Last Will se déclenche.

Comment fonctionnent les liaisons en direct

Quelques détails de ce fichier méritent d'être repris dans tout jumeau numérique Three.js que vous construirez.

Mettre à l'échelle une géométrie de hauteur unitaire. Le cylindre de liquide est créé avec une hauteur de 1, si bien que scale.y est égal à sa hauteur en unités de scène. Placer position.y à la moitié de la hauteur garde le fond ancré au sol de la cuve. C'est moins coûteux et plus simple que de reconstruire la géométrie à chaque message.

Modifier les matériaux, ne pas les remplacer. Appeler material.color.copy(...) met à jour le matériau existant. Créer un nouveau matériau à chaque message provoque des fuites de ressources GPU et des recompilations de shaders.

Converger progressivement vers les cibles. L'expression 1 - Math.exp(-dt * 4) donne un lissage indépendant de la fréquence d'images : le mouvement est identique à 30 ou à 144 images par seconde. Avec un message par seconde, les valeurs brutes feraient sauter la scène une fois par seconde.

Signaler l'obsolescence. Un jumeau qui se fige en silence est pire que pas de jumeau du tout, car les gens lui font confiance. Le HUD signale qu'aucun message n'est arrivé depuis cinq secondes, et le topic status retenu associé au Last Will du simulateur vous indique si l'équipement lui-même a disparu. Les retained messages permettent aussi à une page fraîchement ouverte d'afficher immédiatement le dernier état connu ; voir les retained messages pour les jumeaux numériques pour savoir quand retenir et quand s'en abstenir.

Les pièges courants

Symptôme Cause probable Correction
La page se charge, le HUD reste sur « Connecting... » URL incorrecte ou chemin /mqtt manquant Utilisez wss://broker.connectmq.com:8084/mqtt
Se connecte, puis se déconnecte toutes les quelques secondes Client ID en double entre plusieurs onglets Rendez le client ID aléatoire à chaque chargement de page
Écran noir, erreurs liées aux modules Fichier ouvert d'une manière qui bloque l'import des modules Servez-le avec npx serve . ou un autre serveur statique
Les valeurs se mettent à jour mais le mouvement est saccadé Meshes modifiés dans le callback MQTT Écrivez dans le magasin d'état, animez dans la boucle de rendu
Erreur de contenu mixte sur un site HTTPS Utilisation de ws:// depuis une page https:// Utilisez toujours wss:// sur le port 8084

Si aucun message n'arrive, examinez la connexion WebSocket dans l'onglet Réseau des outils de développement du navigateur, et lisez MQTT sur WebSocket pour une explication complète des ports, des chemins et des proxys.

Des primitives aux vrais modèles

Les boîtes et les cylindres sont parfaits pour valider le chemin des données. L'étape suivante est généralement un modèle glTF exporté depuis Blender ou un outil de CAO, dans lequel vous recherchez les meshes par nom pour leur associer des topics. C'est exactement ce que couvre lier des données IoT à un modèle 3D, y compris les matériaux, les morph targets et les clips d'animation. Si vous voulez la structure complète de bout en bout d'un jumeau web de production avec des liaisons pilotées par configuration, suivez construire un jumeau numérique 3D web. Vous préférez un autre moteur ? Le même pattern MQTT fonctionne avec Babylon.js.

Passer en production

Le serveur MQTT public gratuit est idéal pour ce tutoriel et pour les prototypes, mais il est anonyme et lisible par tous : ne faites donc jamais transiter de vraies données d'usine par lui. En production, il vous faut votre propre broker avec identifiants et TLS. ConnectMQ fournit un broker MQTT managé avec WebSocket sécurisé sur le port 8084 et un certificat Let's Encrypt reconnu, si bien que la connexion du navigateur fonctionne sans aucune configuration de certificat. Ajoutez les identifiants aux options de MQTT.js :

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

Tout ce qui est envoyé à un navigateur est visible par ses utilisateurs : donnez donc à la page de visualisation ses propres identifiants, distincts de ceux des équipements afin de pouvoir les renouveler indépendamment, et ne servez la page qu'à des utilisateurs authentifiés. De nombreuses équipes placent une fine couche de connexion devant la page et injectent les identifiants MQTT après l'authentification.

Démarrer avec ConnectMQ

ConnectMQ propose sa propre démo web de jumeau numérique 3D, une usine avec une machine CNC, un bras robotisé, un four, une cuve, un convoyeur et une pompe mis à jour en direct via WebSocket sécurisé, construite exactement selon le pattern de cet article. Pour faire tourner votre propre jumeau numérique Three.js sur un broker privé, créez un broker gratuit sur app.connectmq.com. Le plan Serverless gratuit ne demande aucune carte bancaire et prend en charge jusqu'à 25 connexions, de quoi accueillir quelques équipements et visualiseurs, et l'explorateur de topics en direct du portail vous aide à vérifier chaque liaison. En attendant, vous pouvez continuer à expérimenter sur le serveur MQTT public gratuit, avec des données de démonstration uniquement.

Questions fréquentes

Three.js peut-il se connecter directement à MQTT ?

Three.js se charge uniquement du rendu ; la connexion est gérée par MQTT.js dans la même page. MQTT.js utilise WebSocket dans le navigateur : le broker doit donc exposer un endpoint MQTT sur WebSocket, comme le port 8084 avec le chemin /mqtt.

Combien d'objets un jumeau numérique dans le navigateur peut-il mettre à jour en temps réel ?

Mettre à jour des transformations et des couleurs de matériaux coûte peu ; des centaines d'objets liés sont donc réalistes sur du matériel ordinaire. Le goulot d'étranglement habituel est la complexité de la géométrie et le nombre de draw calls, pas le traitement des messages MQTT.

Le jumeau doit-il s'abonner en QoS 0 ou en QoS 1 ?

La QoS 0 convient généralement à la télémétrie visuelle, car une valeur plus récente remplace en quelques secondes une valeur perdue. Utilisez la QoS 1 pour les commandes et les alarmes qui ne doivent pas être manquées.

Faut-il un serveur backend pour un jumeau numérique Three.js ?

Pas pour la visualisation en direct : le navigateur communique directement avec le broker. Un backend n'est nécessaire que pour des besoins comme les requêtes historiques, l'authentification des utilisateurs ou l'émission d'identifiants par utilisateur.

Articles liés

Tous les articles →

Votre broker MQTT privé, à quelques clics

Créez un compte, récupérez les identifiants de votre broker et connectez votre premier appareil — aucun serveur à installer, mettre à jour ou dimensionner.