Jumeau numérique

Créer un jumeau numérique 3D web avec glTF et des données MQTT en direct

Un guide pas à pas pour un jumeau numérique 3D web digne de la production : préparation du modèle, projet Vite, liaisons MQTT pilotées par configuration, inspection au clic, état de la connexion et déploiement statique.

Jumeau numérique 3D web d'une petite usine avec four, cuve, pompe et machine CNC affiché 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 3D web permet à quiconque dispose d'un navigateur de voir l'état en direct d'une machine, d'une ligne ou d'une usine entière, sans installer de logiciel. Ce guide explique comment en construire un correctement : préparer un modèle glTF, mettre en place un projet Vite, lier des topics MQTT à des parties du modèle via un fichier de configuration, ajouter l'inspection au clic et déployer le résultat sous forme de site statique. Les données proviennent de broker.connectmq.com, le serveur MQTT public gratuit de ConnectMQ réservé aux tests et aux prototypes, via WebSocket sécurisé, la même approche que celle de la démo d'usine 3D de ConnectMQ.

Architecture d'un jumeau numérique 3D web

Avant d'écrire du code, il est utile de visualiser l'ensemble du système. Un jumeau dans le navigateur est avant tout une application front-end ; c'est le broker qui fait le gros du travail d'acheminement des données.

Couche Responsabilité Technologie dans ce guide
Appareils et passerelles Publier la télémétrie et l'état N'importe quel client MQTT (passerelle d'automate, ESP32, Python)
Broker Router les messages, conserver l'état retenu Broker MQTT managé, WSS sur le port 8084
Modèle Géométrie, parties nommées, matériaux glTF binaire (.glb)
Configuration des liaisons Quel champ de quel topic pilote quelle partie, et comment Un module de configuration JavaScript
Runtime Charger le modèle, s'abonner, animer, interagir Three.js et MQTT.js dans une application Vite

L'idée clé est la configuration des liaisons. Au lieu d'écrire du code pour chaque machine, vous écrivez une poignée de comportements génériques (lueur thermique, niveau de remplissage, rotation, couleur d'état) et une liste qui relie les topics aux parties du modèle. Ajouter une nouvelle pompe revient alors à ajouter une ligne de configuration et un maillage nommé, sans toucher au runtime. Si le concept est nouveau pour vous, la présentation de la technologie du jumeau numérique explique comment les jumeaux visuels s'articulent avec les jumeaux de simulation et d'analyse.

Étape 1 : planifier les équipements et les topics

Une scène d'usine typique, comme la démo mentionnée plus haut, comprend une machine CNC, un bras robotique, un four, une cuve, un convoyeur et une pompe. Nous en prendrons quatre comme exemple. Rédigez d'abord le plan : il deviendra votre fichier de configuration.

Équipement Topic (sous votre préfixe) Champ Comportement visuel
Four furnace1/telemetry temperature (20 à 900) Lueur émissive du sombre à l'orange
Cuve tank1/telemetry level (0 à 1) Le maillage du liquide s'étire sur Y
Pompe pump1/telemetry rpm (0 à 3000) La roue tourne
Machine CNC cnc1/state state Couleur du gyrophare : en marche, à l'arrêt, alarme

Pour la démo, nous utilisons le préfixe connectmq/demo/factory-7f3a ; remplacez le suffixe par votre propre chaîne aléatoire. En production, vous utiliseriez un espace de noms hiérarchique comme acme/plant1/area2/line1/furnace1, et notre article sur MQTT pour les jumeaux numériques explique comment le concevoir.

Étape 2 : préparer le modèle 3D

Quel que soit l'outil d'où provient votre modèle (Blender, un export CAO, un asset acheté), rendez-le prêt pour le jumeau avant de l'exporter :

  1. Nommez chaque partie liée de façon unique et explicite, par exemple Furnace, Tank_Liquid, Pump_Impeller, CNC_Beacon. Les noms survivent à l'export glTF et c'est grâce à eux que le runtime retrouve les parties.
  2. Séparez les parties mobiles en objets distincts avec le pivot au centre de rotation. Une roue fusionnée avec le corps de la pompe ne peut pas tourner seule.
  3. Donnez au liquide une hauteur unitaire avec son origine en bas, afin que la mise à l'échelle sur Y le remplisse depuis le fond.
  4. Réduisez le nombre de polygones de tout ce qui n'est pas visuellement important. Les exports CAO comptent souvent des millions de triangles ; un jumeau dans le navigateur doit généralement rester bien en dessous.
  5. Exportez en glTF binaire (.glb) avec l'option « Apply modifiers » activée. Compressez éventuellement avec Draco ou Meshopt pour accélérer le chargement.

Le code ci-dessous inclut une solution de repli qui construit de simples boîtes portant les mêmes noms si aucun modèle n'est trouvé ; vous pouvez donc suivre le guide sans modèle.

Étape 3 : créer la structure du projet

npm create vite@latest web-twin -- --template vanilla
cd web-twin
npm install three mqtt
mkdir -p public/models   # placez factory.glb ici

Le runtime ci-dessous attend le modèle au niveau supérieur d'un module ; indiquez donc à Vite de cibler les navigateurs modernes en créant vite.config.js :

export default { build: { target: "es2022" } };

Créez un fichier .env pour les paramètres qui varient d'un environnement à l'autre. Vite expose au navigateur les variables préfixées par VITE_ :

VITE_MQTT_URL=wss://broker.connectmq.com:8084/mqtt
VITE_TOPIC_PREFIX=connectmq/demo/factory-7f3a

Étape 4 : écrire la configuration des liaisons

Créez src/bindings.js. Chaque entrée indique quel topic et quel champ lire, quelle partie du modèle modifier et quel comportement appliquer.

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,
};

Étape 5 : le runtime du jumeau

Remplacez src/main.js par le runtime ci-dessous. Il charge le modèle (ou construit des substituts), clone les matériaux pour que les parties puissent changer de couleur indépendamment, se connecte à MQTT, achemine les messages selon les liaisons et anime le tout dans une seule boucle de rendu.

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

// ----- Chargement du modèle avec substituts de repli -----
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); // origine en bas
  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);

// ----- Résoudre les liaisons vers les objets et leur donner des matériaux privés -----
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();
  }
});

// ----- Comportements -----
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;
    });
  },
};

// ----- Inspection au clic -----
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;
});

// ----- Boucle de rendu -----
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);
});

Lancez npm run dev et ouvrez l'URL locale. Alimentez ensuite le jumeau en données depuis un terminal :

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"}'

L'option -r publie des messages retenus : même si vous rechargez la page plus tard, le jumeau affiche immédiatement les dernières valeurs connues au lieu d'une usine vide. Cette seule option fait la différence entre un jumeau qui doit « se réchauffer » et un jumeau correct dès le premier affichage ; les compromis sont détaillés dans les messages MQTT retenus pour les jumeaux numériques.

Des choix de conception qui portent leurs fruits

La configuration plutôt que le code. Quand l'usine reçoit un deuxième four, vous ajoutez une entrée dans bindings.js et un maillage nommé Furnace_2. Le runtime ne change pas, ce qui permet aussi de générer facilement la configuration à partir d'une base de données d'équipements par la suite.

Des matériaux privés pour chaque partie liée. Les exporteurs glTF partagent souvent un même matériau entre de nombreux maillages. Sans l'étape clone(), faire rougeoyer un four ferait rougeoyer tous les objets utilisant ce matériau.

Un état qui peut devenir obsolète. Le comportement status passe au gris si aucun message d'état n'arrive pendant dix secondes. Un jumeau ne doit jamais afficher « en marche » pour une machine dont la passerelle est tombée il y a une heure.

Un seul abonnement. Un unique abonnement générique sous le préfixe maintient le client bien en deçà de la limite de 10 abonnements par client du serveur MQTT public gratuit, et la table de routage effectue la répartition par topic en local.

Pour approfondir la couche de mapping, y compris les morph targets et les clips d'animation, consultez lier des données IoT à un modèle 3D. Pour placer des cartes d'indicateurs et des graphiques à côté de la vue 3D, poursuivez avec le tutoriel tableau de bord de jumeau numérique.

Étape 6 : compiler et déployer

Le jumeau est un site statique, l'hébergement est donc simple :

npm run build      # génère dist/
npx serve dist     # vérification locale du build de production

Téléversez dist/ sur n'importe quel hébergeur statique ou stockage objet avec CDN. Avant cela, passez en revue cette liste de contrôle :

  1. Faites pointer VITE_MQTT_URL vers votre broker privé et définissez un préfixe de topic de production.
  2. N'embarquez jamais les identifiants des appareils dans le bundle. Le navigateur dispose de ses propres identifiants, et la page se trouve derrière votre authentification.
  3. Servez le site en HTTPS ; les navigateurs bloquent les connexions ws:// depuis des pages HTTPS, c'est pourquoi le jumeau utilise wss:// sur le port 8084.
  4. Compressez le modèle et vérifiez le temps de chargement sur une connexion lente.
  5. Testez ce qui se passe quand le broker est injoignable : le panneau doit indiquer « MQTT disconnected » et les états doivent passer au gris.

Migrer le jumeau vers votre propre broker

Le serveur MQTT public gratuit est idéal pour développer et faire des démos, mais tout ce qui y transite est visible par tous. Pour une vraie usine, un broker MQTT managé vous apporte identifiants, TLS et isolation sans avoir à exploiter de serveurs. Avec ConnectMQ, vous créez un broker dans le portail, copiez le nom d'utilisateur, le mot de passe et le client ID générés, puis ajoutez VITE_MQTT_USERNAME, VITE_MQTT_PASSWORD, VITE_MQTT_CLIENT_ID et l'URL à votre environnement de production. Le broker détecte aussi les hiérarchies de topics de type ISA-95 et peut republier les données Sparkplug B dans des topics ISA-95 lisibles, ce qui offre au jumeau web des topics propres, faciles à exploiter en JSON, même lorsque l'atelier parle Sparkplug.

Démarrer avec ConnectMQ

Le moyen le plus rapide de voir un résultat abouti est la démo web de jumeau numérique 3D de ConnectMQ, qui diffuse des données en direct vers une scène d'usine via WebSocket sécurisé. Quand vous êtes prêt à lancer votre propre jumeau numérique 3D web, inscrivez-vous sur app.connectmq.com pour le plan Serverless gratuit, sans carte bancaire, et faites pointer VITE_MQTT_URL vers votre nouveau broker. Vous hésitez encore entre les modèles d'hébergement ? Notre guide du broker MQTT cloud compare les options.

Questions fréquentes

Quel format 3D est le plus adapté à un jumeau numérique web ?

Le glTF binaire (.glb) est la norme du web : compact, rapide à charger et pris en charge nativement par Three.js et Babylon.js. Convertissez les formats CAO comme STEP ou FBX en glTF et simplifiez-les avant utilisation.

Comment le jumeau affiche-t-il des valeurs correctes dès le chargement de la page ?

Les appareils publient leur dernier état sous forme de messages retenus ; le broker livre donc la dernière valeur de chaque topic dès que le navigateur s'abonne. Sans messages retenus, le jumeau reste vide jusqu'à l'arrivée de la mise à jour suivante.

Peut-on piloter des machines depuis un jumeau numérique 3D web ?

Oui, en publiant sur des topics de commande depuis la page, par exemple lorsqu'un utilisateur clique sur une machine. Traitez les commandes avec prudence : exigez une confirmation, utilisez la QoS 1 et laissez l'appareil republier son état réel.

Un jumeau dans le navigateur fonctionne-t-il sur tablette ?

Oui. WebGL fonctionne sur les navigateurs mobiles modernes. Gardez un nombre de polygones et des tailles de textures raisonnables, et limitez le ratio de pixels comme le fait l'exemple pour conserver une fréquence d'images fluide.

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.