Pour lier des données IoT à un modèle 3D, il vous faut une petite couche bien définie qui prend un message d'un topic, en extrait une valeur, la transforme et l'applique à une partie précise du modèle. Ce guide construit cette couche pour des modèles glTF dans Three.js : vous associerez des valeurs MQTT à des transformations, à la visibilité, aux couleurs des matériaux, aux morph targets et aux clips d'animation, et vous stockerez les définitions de liaison dans le modèle lui-même grâce aux extras glTF. Les données proviennent du serveur MQTT public gratuit broker.connectmq.com, réservé aux tests et aux prototypes : vous pouvez donc tester chaque liaison depuis un terminal.
Anatomie d'une liaison
Chaque liaison, quel que soit le moteur, répond à cinq questions :
| Question | Exemple | Champ dans notre liaison |
|---|---|---|
| Quel topic ? | connectmq/demo/bind-7f3a/press1/telemetry |
topic (prend en charge + et #) |
| Quelle valeur dans le payload ? | hydraulics.pressure |
path |
| Comment est-elle transformée ? | 0 à 250 bar devient 0 à 1 | map: [inMin, inMax, outMin, outMax], clamp |
| Quelle partie du modèle ? | Nœud nommé Press_Ram |
node |
| Quelle propriété change ? | Position sur Y, couleur émissive, vitesse d'animation | kind et ses options |
Décrire tout cela sous forme de données plutôt que de code est la décision la plus importante d'un jumeau. Le code contient quelques « kinds » génériques ; les données disent quel topic pilote quelle partie. Quand un ingénieur ajoute un capteur, personne ne modifie de JavaScript. Si vous n'avez encore jamais construit de jumeau, commencez par le tutoriel jumeau numérique avec Three.js, puis revenez ici pour la couche de mapping, et consultez le guide de la technologie du jumeau numérique pour le contexte plus large.
Préparer le modèle glTF pour la liaison de données
Un modèle exporté pour des rendus marketing est rarement prêt à être lié à des données. Corrigez ces points dans Blender (ou votre outil DCC) avant l'export :
- Des noms de nœuds uniques et stables.
Press_Ram,Press_Beacon,Tank_Liquid. Évitez les noms commeCylinder.014qui changent à chaque réexport. - Des pivots corrects. Un bras rotatif a besoin de son origine à l'articulation ; un niveau de remplissage a besoin de son origine en bas.
- Des objets séparés pour les parties liées. Si le gyrophare est fusionné avec le corps de la machine, changer sa couleur colore toute la machine.
- Des morph targets (shape keys) pour les déformations continues, comme un flexible qui s'étire ou un cadran, et des clips d'animation (actions) pour les mouvements cycliques, comme un convoyeur ou un ventilateur.
- Des propriétés personnalisées pour les liaisons. Blender exporte les propriétés personnalisées des objets sous forme d'
extrasglTF lorsque l'option « Include > Custom Properties » est activée, et Three.js les place dansobject.userData.
Ce dernier point permet à l'artiste 3D et à l'ingénieur automaticien de travailler dans un seul fichier. Ajoutez à Press_Ram une propriété personnalisée nommée binding avec une chaîne JSON pour valeur :
{
"topic": "connectmq/demo/bind-7f3a/press1/telemetry",
"path": "ram.position",
"map": [0, 400, 0, -1.2],
"clamp": true,
"kind": "translate",
"axis": "y"
}
Après export et chargement, object.userData.binding contient cette chaîne.
Les types de liaison qui couvrent la plupart des jumeaux
Il en faut étonnamment peu. Ces six types couvrent l'essentiel des visualisations industrielles :
| Type | S'applique à | Données typiques | Implémentation |
|---|---|---|---|
translate |
Position d'un nœud sur un axe | Course d'un vérin, hauteur de levage | Décalage par rapport à la position de repos |
rotate |
Rotation d'un nœud sur un axe | Ouverture de vanne, angle de bras | Angle par rapport à la rotation de repos |
spin |
Rotation continue | Vitesse moteur, vitesse de ventilateur | Ajouter value * dt à chaque image |
color |
Couleur ou émissivité du matériau | Température, énumération d'état | Dégradé ou table de correspondance |
morph |
Influence d'un morph target | Niveau de remplissage, déformation | morphTargetInfluences[i] |
clip |
Vitesse ou poids d'un clip d'animation | Vitesse du convoyeur, cycle en cours | AnimationAction.timeScale |
La visibilité est un cas particulier de correspondance de type color (visible: true/false à partir d'un booléen), et les étiquettes de texte sont généralement plus efficaces en overlays HTML qu'en texte 3D.
Le moteur de liaison
Le module ci-dessous charge un glTF, collecte les liaisons depuis userData, fait correspondre les topics MQTT entrants (y compris avec caractères génériques), extrait et transforme les valeurs, puis les applique à chaque image. Enregistrez-le sous binding-engine.js ; il suppose que Three.js et MQTT.js sont disponibles via une import map ou un bundler, comme dans les tutoriels précédents.
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
// ---------- utilitaires ----------
export function topicMatches(filter, topic) {
const f = filter.split("/"), t = topic.split("/");
for (let i = 0; i < f.length; i++) {
if (f[i] === "#") return true;
if (i >= t.length) return false;
if (f[i] !== "+" && f[i] !== t[i]) return false;
}
return f.length === t.length;
}
const getPath = (obj, path) =>
path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
function transform(value, b) {
if (b.lookup) return b.lookup[value] ?? b.lookup.default;
let v = Number(value);
if (Number.isNaN(v)) return undefined;
if (b.map) {
const [a, c, d, e] = b.map;
v = d + ((v - a) / (c - a)) * (e - d);
if (b.clamp) v = THREE.MathUtils.clamp(v, Math.min(d, e), Math.max(d, e));
}
return v;
}
// ---------- moteur ----------
export async function createTwin(scene, url) {
const gltf = await new GLTFLoader().loadAsync(url);
scene.add(gltf.scene);
const mixer = new THREE.AnimationMixer(gltf.scene);
const bindings = [];
gltf.scene.traverse((obj) => {
if (!obj.userData.binding) return;
let b;
try { b = JSON.parse(obj.userData.binding); } catch { console.warn("Bad binding on", obj.name); return; }
b.obj = obj;
b.rest = { position: obj.position.clone(), rotation: obj.rotation.clone() };
b.target = b.current = undefined;
if (b.kind === "color") {
obj.traverse((o) => { if (o.isMesh) o.material = o.material.clone(); });
}
if (b.kind === "clip") {
const clip = THREE.AnimationClip.findByName(gltf.animations, b.clip);
if (clip) { b.action = mixer.clipAction(clip); b.action.play(); b.action.timeScale = 0; }
else console.warn(`Clip ${b.clip} not found`);
}
bindings.push(b);
});
function onMessage(topic, payload) {
let data;
const text = new TextDecoder().decode(payload);
try { data = JSON.parse(text); } catch { data = text; }
for (const b of bindings) {
if (!topicMatches(b.topic, topic)) continue;
const raw = b.path ? getPath(data, b.path) : data;
const v = transform(raw, b);
if (v !== undefined) b.target = v;
}
}
const color = new THREE.Color();
function update(dt) {
const k = 1 - Math.exp(-dt * 5);
for (const b of bindings) {
if (b.target === undefined) continue;
if (typeof b.target === "number") {
b.current = b.current === undefined ? b.target : b.current + (b.target - b.current) * k;
} else {
b.current = b.target; // chaînes, couleurs, booléens : pas de lissage
}
const o = b.obj, v = b.current;
switch (b.kind) {
case "translate": o.position[b.axis] = b.rest.position[b.axis] + v; break;
case "rotate": o.rotation[b.axis] = b.rest.rotation[b.axis] + THREE.MathUtils.degToRad(v); break;
case "spin": o.rotation[b.axis] += v * dt; break;
case "visible": o.visible = Boolean(v); break;
case "color":
color.set(typeof v === "number"
? new THREE.Color(b.from).lerp(new THREE.Color(b.to), v)
: v);
o.traverse((m) => {
if (!m.isMesh) return;
m.material[b.channel ?? "color"].copy(color);
});
break;
case "morph":
o.traverse((m) => {
if (m.morphTargetInfluences && m.morphTargetDictionary?.[b.morph] !== undefined) {
m.morphTargetInfluences[m.morphTargetDictionary[b.morph]] = v;
}
});
break;
case "clip": if (b.action) b.action.timeScale = v; break;
}
}
mixer.update(dt);
}
const filters = [...new Set(bindings.map((b) => b.topic))];
return { bindings, filters, onMessage, update };
}
Intégrez-le dans une page :
import * as THREE from "three";
import mqtt from "mqtt";
import { createTwin } from "./binding-engine.js";
// scène, caméra et renderer configurés comme d'habitude ...
const twin = await createTwin(scene, "/models/press.glb");
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "bind-viewer-" + crypto.randomUUID().slice(0, 8),
protocolVersion: 5,
});
client.on("connect", () => client.subscribe(twin.filters, { qos: 0 }));
client.on("message", twin.onMessage);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
twin.update(clock.getDelta());
renderer.render(scene, camera);
});
S'abonner exactement aux filtres déclarés par le modèle, plutôt qu'à #, limite le trafic. Sur le serveur MQTT public gratuit, tenez compte de la limite de 10 abonnements par client ; si un modèle déclare davantage de filtres, abonnez-vous plutôt à un parent commun avec un caractère générique.
Autres exemples de liaison
Le même moteur gère des pièces très différentes avec de simples données. Un gyrophare d'état piloté par une énumération, avec une table de correspondance et le canal émissif :
{
"topic": "connectmq/demo/bind-7f3a/press1/state",
"path": "state",
"lookup": { "RUNNING": "#22c55e", "IDLE": "#eab308", "FAULT": "#ef4444", "default": "#6b7280" },
"kind": "color",
"channel": "emissive"
}
Une cuve dont le niveau est un morph target nommé Fill, alimenté par un pourcentage :
{ "topic": "connectmq/demo/bind-7f3a/tank1/telemetry", "path": "level_pct",
"map": [0, 100, 0, 1], "clamp": true, "kind": "morph", "morph": "Fill" }
Une animation de bande de convoyeur dont la vitesse suit la fréquence de l'entraînement (à 50 Hz, le clip est joué à vitesse normale) :
{ "topic": "connectmq/demo/bind-7f3a/conv1/telemetry", "path": "drive.hz",
"map": [0, 50, 0, 1], "clamp": true, "kind": "clip", "clip": "BeltLoop" }
Testez n'importe quelle liaison directement depuis un terminal :
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-bind-7f3a -r \
-t "connectmq/demo/bind-7f3a/press1/telemetry" -m '{"ram":{"position":220}}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-bind-7f3a -r \
-t "connectmq/demo/bind-7f3a/press1/state" -m '{"state":"FAULT"}'
L'option -r retient le message : le modèle affiche la valeur dès son chargement. L'état retenu est la colonne vertébrale d'un jumeau correct dès le premier affichage ; les messages MQTT retenus pour les jumeaux numériques explique quand l'utiliser.
Les pièges à éviter quand on lie des données IoT à un modèle 3D
- Les matériaux partagés. Les exporteurs glTF réutilisent les matériaux. Clonez-les pour toute pièce dont la couleur est liée, sinon une seule alarme fait virer toute l'usine au rouge.
- Transformations absolues ou relatives. Appliquez toujours les valeurs par rapport à la pose de repos capturée au chargement. Sinon, la pièce saute à l'origine du monde.
- Degrés ou radians. Les capteurs fournissent des degrés ; Three.js attend des radians. Convertissez à un seul endroit, comme le fait
rotate. - Les valeurs manquantes. Les payloads évoluent. Si
pathse résout enundefined, conservez la valeur précédente au lieu de retomber à zéro. - Les données obsolètes. Enregistrez la date de dernière mise à jour de chaque liaison et assombrissez les pièces dont l'appareil ne s'est pas manifesté depuis un moment.
- Un nœud renommé dans le modèle. Journalisez un avertissement pour chaque liaison dont le nœud ou le clip est introuvable au chargement ; les échecs silencieux sont les plus difficiles à déboguer.
- Trop de lissage. Le lissage convient aux valeurs continues mais masque les événements discrets. Ne lissez pas les énumérations, les booléens ni les états d'alarme.
Les budgets de latence de chacune de ces étapes sont traités dans la latence d'un jumeau numérique temps réel.
D'où viennent les données
Les liaisons ne valent que ce que valent les topics qui les alimentent. Une hiérarchie cohérente comme site/area/line/cell/asset/metric permet de lier avec des caractères génériques et de réutiliser les modèles d'une ligne à l'autre. Si votre usine publie en Sparkplug B, ConnectMQ peut le décoder et le republier dans des topics lisibles de type ISA-95, comme connectmq/factory1/production/line1/cnc1/telemetry, bien plus faciles à lier que des payloads Protobuf. Le guide Sparkplug B et Unified Namespace explique cette structure. Pour une application complète utilisant ce moteur avec configuration et déploiement, consultez créer un jumeau numérique 3D web, ou suivez le tutoriel jumeau numérique d'un convoyeur pour un exemple ciblé.
Démarrer avec ConnectMQ
Une fois vos liaisons opérationnelles sur le serveur MQTT public gratuit, passez à un broker privé. Un broker MQTT managé ConnectMQ fournit un WebSocket sécurisé sur le port 8084 avec un certificat reconnu, ainsi qu'un explorateur de topics en direct qui montre exactement quels chemins existent dans vos payloads, ce qui est précieux pour écrire des liaisons. Créez un broker gratuit sur app.connectmq.com ; le plan Serverless ne demande aucune carte bancaire. Continuez d'utiliser le serveur MQTT public gratuit pour des essais rapides avec des données non sensibles.
Questions fréquentes
Où placer les définitions de liaison : dans le modèle ou dans le code ?
Les deux fonctionnent. Les stocker sous forme d'extras glTF réunit modèle et mapping de données pour les petites équipes, tandis qu'une configuration JSON séparée est plus facile à générer depuis une base de données d'équipements et à relire dans le contrôle de version.
Peut-on lier des données à des animations exportées depuis Blender ?
Oui. Les actions exportées deviennent des clips d'animation glTF. Jouez-les via un AnimationMixer et liez le timeScale de l'action pour la vitesse, ou son poids pour passer d'un état à l'autre en fondu.
Comment lier des données à une pièce qui n'a pas de maillage séparé ?
On ne peut pas déplacer ni recolorer indépendamment une partie d'un maillage fusionné. Séparez la pièce en objet distinct dans votre outil de modélisation et réexportez, ou utilisez un morph target si le changement est une déformation.
Cette approche fonctionne-t-elle dans Babylon.js ou Unity ?
Le modèle de liaison topic, chemin, transformation, nœud et type est indépendant du moteur. Seule l'étape d'application change, en utilisant les API propres à chaque moteur pour les transformations, les matériaux et les animations.



