Un jumeau numérique Babylon.js vous offre un moteur 3D complet dans le navigateur, avec sélection d'objets, effets de surbrillance et inspecteur intégrés, ce qui permet de construire rapidement des jumeaux interactifs. Dans ce tutoriel, vous allez créer un petit skid de refroidissement (une pompe, une cuve et un manomètre) qui réagit à la télémétrie MQTT en direct via WebSocket sécurisé, s'illumine en rouge en cas de surpression et vous permet de démarrer ou d'arrêter la pompe d'un clic. Toutes les données transitent par le serveur MQTT public gratuit broker.connectmq.com, réservé aux tests et aux prototypes, et un simulateur Python joue le rôle de l'équipement réel.
Pourquoi choisir Babylon.js pour un jumeau numérique
Babylon.js et Three.js affichent tous deux des scènes WebGL dans le navigateur et fonctionnent bien avec MQTT. La différence tient à ce qui est fourni d'emblée :
| Fonctionnalité | Babylon.js | Three.js |
|---|---|---|
| Sélection et actions du pointeur | Intégrées (ActionManager, scene.pick) |
Raycaster, vous câblez les événements vous-même |
| Effets de lueur et de surbrillance | HighlightLayer, GlowLayer intégrés |
Passes de post-traitement à assembler |
| Inspecteur de débogage | scene.debugLayer.show() |
Outils tiers |
| Taille du bundle et style d'API | Plus lourd, plus complet | Plus léger, plus modulaire |
| Chargement glTF | Paquet de loaders | Add-on GLTFLoader |
Si vous préférez une bibliothèque plus légère, notre tutoriel jumeau numérique avec Three.js construit une scène similaire. Si le jumeau repose surtout sur l'interaction (cliquer sur une machine, voir les détails, envoyer une commande), Babylon.js vous épargne beaucoup de plomberie. Les concepts plus généraux sont traités dans notre guide de la technologie du jumeau numérique.
Topics et payloads
Le jumeau utilise trois topics sous un préfixe unique. Remplacez bjs-7f3a par votre propre suffixe aléatoire :
| Topic | Éditeur | Payload | Comportement du jumeau |
|---|---|---|---|
connectmq/demo/bjs-7f3a/skid1/telemetry |
Équipement | {"pressure":3.2,"level":0.55,"running":true} |
Aiguille du manomètre, niveau de la cuve, rotation de la pompe |
connectmq/demo/bjs-7f3a/skid1/cmd |
Jumeau (au clic) | START ou STOP |
Le simulateur change l'état de la pompe |
connectmq/demo/bjs-7f3a/skid1/status |
Équipement, retenu, last will | online / offline |
Passage au gris hors ligne |
La pression est en bar, le niveau est une fraction de 0 à 1. Des unités fixes et documentées évitent le bug de jumeau le plus courant : un manomètre qui affiche des kilopascals comme des bars.
Étape 1 : le simulateur d'équipement
Installez paho-mqtt 2.x (pip install "paho-mqtt>=2.0") et lancez ce script. Il simule une pression qui monte lentement pendant que la pompe tourne, avec des pics occasionnels au-delà du seuil d'alarme de 6 bar, et obéit aux commandes du jumeau.
import json
import math
import random
import time
import paho.mqtt.client as mqtt
P = "connectmq/demo/bjs-7f3a/skid1"
state = {"running": True}
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code)
client.publish(f"{P}/status", "online", qos=1, retain=True)
client.subscribe(f"{P}/cmd", qos=1)
def on_message(client, userdata, msg):
cmd = msg.payload.decode().strip().upper()
if cmd in ("START", "STOP"):
state["running"] = cmd == "START"
print("command", cmd)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="bjs-sim-7f3a")
client.will_set(f"{P}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
t, level = 0, 0.5
while True:
t += 1
if state["running"]:
pressure = 4.0 + 1.2 * math.sin(t / 10) + (2.5 if random.random() < 0.05 else 0)
level = max(0.1, level - 0.01)
else:
pressure = max(0.2, 1.0 + random.uniform(-0.1, 0.1))
level = min(0.95, level + 0.02)
payload = {"pressure": round(pressure, 2), "level": round(level, 2),
"running": state["running"]}
client.publish(f"{P}/telemetry", json.dumps(payload), qos=0, retain=True)
time.sleep(1)
La télémétrie est publiée en mode retenu : le jumeau affiche les dernières valeurs dès sa connexion au lieu d'attendre une seconde. Vérifiez le flux avec mosquitto_sub -h broker.connectmq.com -p 1883 -i chk-7f3a -v -t "connectmq/demo/bjs-7f3a/#".
Étape 2 : construire le jumeau numérique Babylon.js
Enregistrez ce fichier sous index.html et servez-le avec n'importe quel serveur statique (npx serve .). Il charge Babylon.js depuis le CDN officiel et MQTT.js sous forme de bundle navigateur. En production, figez les deux sur des versions exactes que vous avez testées.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Babylon.js Digital Twin</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #111827; }
#c { width: 100%; height: 100%; touch-action: none; }
#info { position: absolute; top: 10px; left: 10px; color: #fff; font: 14px system-ui;
background: rgba(0,0,0,.6); padding: 8px 12px; border-radius: 6px; }
</style>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
</head>
<body>
<canvas id="c"></canvas>
<div id="info">Connecting...</div>
<script>
const PREFIX = "connectmq/demo/bjs-7f3a/skid1";
const ALARM_BAR = 6.0;
const info = document.getElementById("info");
const canvas = document.getElementById("c");
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0.07, 0.09, 0.15, 1);
const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI / 3, Math.PI / 3, 14, new BABYLON.Vector3(0, 1, 0), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0.3, 1, 0.2), scene);
BABYLON.MeshBuilder.CreateGround("ground", { width: 16, height: 10 }, scene);
const mat = (name, hex) => {
const m = new BABYLON.StandardMaterial(name, scene);
m.diffuseColor = BABYLON.Color3.FromHexString(hex);
return m;
};
// Pompe : corps + rotor
const pump = BABYLON.MeshBuilder.CreateCylinder("pump", { diameter: 1.6, height: 1.2 }, scene);
pump.rotation.z = Math.PI / 2;
pump.position.set(-3, 0.8, 0);
pump.material = mat("pumpMat", "#3b82f6");
const rotor = BABYLON.MeshBuilder.CreateBox("rotor", { width: 0.1, height: 1.4, depth: 0.2 }, scene);
rotor.position.set(-2.3, 0.8, 0);
rotor.material = mat("rotorMat", "#e5e7eb");
// Cuve avec un liquide mis à l'échelle sur Y (origine déplacée en bas)
const shell = BABYLON.MeshBuilder.CreateCylinder("shell", { diameter: 2.2, height: 3 }, scene);
shell.position.set(1, 1.5, 0);
shell.material = mat("shellMat", "#9ca3af");
shell.material.alpha = 0.25;
const liquid = BABYLON.MeshBuilder.CreateCylinder("liquid", { diameter: 2, height: 1 }, scene);
liquid.setPivotPoint(new BABYLON.Vector3(0, -0.5, 0));
liquid.position.set(1, 0.5, 0);
liquid.material = mat("liquidMat", "#0ea5e9");
// Manomètre : cadran + aiguille pivotant à sa base
const dial = BABYLON.MeshBuilder.CreateDisc("dial", { radius: 0.9, tessellation: 48 }, scene);
dial.position.set(4.5, 2, -0.01);
dial.material = mat("dialMat", "#f9fafb");
const needle = BABYLON.MeshBuilder.CreateBox("needle", { width: 0.06, height: 0.8, depth: 0.02 }, scene);
needle.setPivotPoint(new BABYLON.Vector3(0, -0.4, 0));
needle.position.set(4.5, 2.4, -0.05);
needle.material = mat("needleMat", "#dc2626");
const highlight = new BABYLON.HighlightLayer("hl", scene);
// ---------- État ----------
const target = { pressure: 0, level: 0, running: false, online: false };
const cur = { pressure: 0, level: 0, rotorSpeed: 0 };
let alarmOn = false;
// ---------- MQTT ----------
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "bjs-twin-" + Math.random().toString(16).slice(2, 10),
protocolVersion: 5,
reconnectPeriod: 2000,
});
const dec = new TextDecoder();
client.on("connect", () => client.subscribe(`${PREFIX}/#`, { qos: 1 }));
client.on("message", (topic, payload) => {
const text = dec.decode(payload);
if (topic === `${PREFIX}/status`) { target.online = text === "online"; return; }
if (topic !== `${PREFIX}/telemetry`) return;
try {
const d = JSON.parse(text);
if (typeof d.pressure === "number") target.pressure = d.pressure;
if (typeof d.level === "number") target.level = Math.min(Math.max(d.level, 0), 1);
if (typeof d.running === "boolean") target.running = d.running;
} catch { /* ignorer les payloads malformés */ }
});
// ---------- Cliquer sur la pompe pour la basculer ----------
pump.actionManager = new BABYLON.ActionManager(scene);
pump.actionManager.registerAction(new BABYLON.ExecuteCodeAction(
BABYLON.ActionManager.OnPickTrigger, () => {
const cmd = target.running ? "STOP" : "START";
if (confirm(`Send ${cmd} to pump?`)) client.publish(`${PREFIX}/cmd`, cmd, { qos: 1 });
}));
pump.actionManager.registerAction(new BABYLON.SetValueAction(
BABYLON.ActionManager.OnPointerOverTrigger, pump, "scaling", new BABYLON.Vector3(1.05, 1.05, 1.05)));
pump.actionManager.registerAction(new BABYLON.SetValueAction(
BABYLON.ActionManager.OnPointerOutTrigger, pump, "scaling", new BABYLON.Vector3(1, 1, 1)));
// ---------- Mise à jour à chaque image ----------
scene.onBeforeRenderObservable.add(() => {
const dt = engine.getDeltaTime() / 1000;
const k = 1 - Math.exp(-dt * 4);
cur.pressure += (target.pressure - cur.pressure) * k;
cur.level += (target.level - cur.level) * k;
cur.rotorSpeed += ((target.running ? 12 : 0) - cur.rotorSpeed) * k;
liquid.scaling.y = Math.max(cur.level * 2.9, 0.01);
rotor.rotation.x += cur.rotorSpeed * dt;
// 0 bar = -120 degrés, 10 bar = +120 degrés
needle.rotation.z = -BABYLON.Tools.ToRadians(-120 + (Math.min(cur.pressure, 10) / 10) * 240);
pump.material.diffuseColor = BABYLON.Color3.FromHexString(
!target.online ? "#6b7280" : target.running ? "#22c55e" : "#3b82f6");
const shouldAlarm = target.online && target.pressure > ALARM_BAR;
if (shouldAlarm !== alarmOn) {
alarmOn = shouldAlarm;
if (alarmOn) highlight.addMesh(pump, BABYLON.Color3.Red());
else highlight.removeMesh(pump);
}
info.textContent = `${client.connected ? "Live" : "Disconnected"} | ` +
`${cur.pressure.toFixed(2)} bar | level ${(cur.level * 100).toFixed(0)} % | ` +
`pump ${target.running ? "running" : "stopped"}${alarmOn ? " | HIGH PRESSURE" : ""}`;
});
engine.runRenderLoop(() => scene.render());
addEventListener("resize", () => engine.resize());
</script>
</body>
</html>
Ouvrez la page pendant que le simulateur tourne. La cuve se vide lentement pendant que la pompe fonctionne, l'aiguille suit la pression, la pompe s'illumine en rouge dès que la pression dépasse 6 bar, et un clic sur la pompe envoie une commande après confirmation. Arrêtez le simulateur avec Ctrl+C : dès que le broker publie le last will, la pompe devient grise.
Comment fonctionnent les liaisons Babylon.js
Des points de pivot plutôt que des modifications de géométrie. setPivotPoint déplace l'origine de mise à l'échelle et de rotation au bas du liquide et à la base de l'aiguille : le remplissage part du fond et l'aiguille tourne autour de son axe. C'est l'équivalent Babylon.js du réglage d'une origine dans Blender.
onBeforeRenderObservable comme point de mise à jour unique. Le handler MQTT ne fait qu'écrire dans target. Tout ce qui est visuel se passe une fois par image, lissé par un facteur indépendant de la fréquence d'images. L'animation reste ainsi fluide même quand les messages arrivent de façon irrégulière.
Des changements d'état, pas du travail à chaque image. La couche de surbrillance n'est modifiée que lorsque l'état d'alarme bascule. Ajouter et retirer des maillages à chaque image gaspillerait des ressources et pourrait provoquer des scintillements.
Des commandes avec confirmation. Un clic sur un objet 3D se fait facilement par accident, surtout en faisant pivoter la caméra. La boîte de dialogue confirm() est la protection minimale ; les déploiements réels exigent souvent aussi une vérification de rôle. Les commandes utilisent la QoS 1 pour résister à une brève coupure réseau ; les niveaux de QoS MQTT expliqués montre pourquoi.
L'appareil confirme le résultat. La couleur de la pompe reflète le champ running de la télémétrie, pas le bouton sur lequel vous avez appuyé. Si l'équipement refuse ou manque la commande, le jumeau montre la réalité.
Conseils de performance pour les jumeaux plus importants
Un skid de trois maillages tourne sans effort, mais une ligne complète avec des centaines de pièces demande un peu d'attention. Figez les maillages qui ne bougent jamais avec mesh.freezeWorldMatrix() pour que Babylon.js saute leurs mises à jour de matrice. Partagez les matériaux entre les pièces statiques et ne clonez que ceux dont la couleur est liée aux données. Utilisez des instances (mesh.createInstance) pour les éléments répétés comme les rouleaux ou les palettes. Gardez une seule connexion MQTT par page avec un abonnement générique et répartissez les messages en local, plutôt que d'ouvrir une connexion par équipement. Enfin, limitez la fréquence des overlays texte : réécrire des éléments DOM à 60 images par seconde coûte souvent plus cher que le rendu 3D lui-même ; mettez-les plutôt à jour quelques fois par seconde.
Liste de dépannage
- Bloqué sur « Connecting... » : vérifiez que l'URL est bien
wss://broker.connectmq.com:8084/mqtt, chemin/mqttcompris, et consultez la console du navigateur pour repérer les erreurs WebSocket. Les proxys d'entreprise bloquent parfois l'upgrade WebSocket. BABYLON is not defined: la balise script de Babylon.js n'a pas pu se charger ; vérifiez l'onglet réseau et une éventuelle politique de sécurité du contenu.- Les valeurs n'apparaissent jamais : le préfixe de topic de la page diffère de celui du simulateur. Comparez-les caractère par caractère.
- La connexion tombe à répétition avec deux onglets ouverts : chaque onglet doit utiliser un client ID unique, ce que garantit le suffixe aléatoire.
- Utilisez l'inspecteur : appelez
scene.debugLayer.show()dans la console pour inspecter en direct maillages, matériaux et pivots.
Pour en savoir plus sur les ports et chemins WebSocket, consultez MQTT sur WebSocket.
Des primitives à un vrai modèle d'usine
Remplacez les primitives par un modèle glTF en ajoutant le script des loaders Babylon.js et en appelant BABYLON.SceneLoader.ImportMeshAsync("", "/models/", "skid.glb", scene), puis retrouvez les pièces avec scene.getMeshByName("Pump_Body"). Les conventions de nommage et les patterns de mapping sont les mêmes quel que soit le moteur ; lier des données IoT à un modèle 3D les explique, et créer un jumeau numérique 3D web présente une structure pilotée par configuration qui s'étend à une usine entière.
Production : votre propre broker
Le serveur MQTT public gratuit est anonyme et visible par tous, ce qui convient à ce tutoriel mais pas à une usine. Un broker MQTT managé vous apporte identifiants, TLS et isolation. Avec ConnectMQ, vous créez un broker dans le portail et recevez un nom d'utilisateur, un mot de passe et un client ID générés ; ajoutez-les aux options de mqtt.connect sous username, password et clientId (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID). Le WebSocket sécurisé sur le port 8084 utilise un certificat Let's Encrypt, que les navigateurs reconnaissent sans configuration. Servez la page du jumeau derrière votre propre authentification, car tout ce qui est livré au navigateur peut être lu par ses utilisateurs.
Démarrer avec ConnectMQ
La démo web de jumeau numérique 3D de ConnectMQ présente une scène d'usine complète diffusée via WebSocket sécurisé, et le même broker est disponible pour votre propre jumeau numérique Babylon.js. Créez un broker gratuit sur app.connectmq.com : le plan Serverless ne demande aucune carte bancaire, prend en charge MQTT 5 et jusqu'à 25 connexions, et inclut un explorateur de topics en direct pour vérifier votre télémétrie avant de la lier. D'ici là, le serveur MQTT public gratuit est prêt pour vos expériences.
Questions fréquentes
Babylon.js ou Three.js : lequel est le meilleur pour les jumeaux numériques ?
Les deux fonctionnent bien. Babylon.js intègre davantage de fonctionnalités, comme les actions de sélection, les couches de surbrillance et un inspecteur, tandis que Three.js est plus léger et plus modulaire ; choisissez celui avec lequel votre équipe est le plus à l'aise.
Comment une page Babylon.js se connecte-t-elle à MQTT ?
Grâce à MQTT.js, qui s'exécute dans la même page via WebSocket, car les navigateurs ne peuvent pas ouvrir de connexions TCP brutes. Le broker doit proposer un point d'accès MQTT sur WebSocket, idéalement en WebSocket sécurisé.
Peut-on cliquer sur des objets du jumeau pour piloter des équipements ?
Oui. Attachez un ActionManager à un maillage et publiez une commande sur OnPickTrigger. Ajoutez une confirmation et laissez l'appareil renvoyer son état réel.
Comment charger mon propre modèle CAO dans Babylon.js ?
Convertissez-le en glTF (.glb), réduisez son nombre de polygones, donnez des noms uniques aux pièces liées et chargez-le avec SceneLoader.ImportMeshAsync.



