Jumeau numérique

Jumeau numérique Unity connecté à MQTT : télémétrie en direct et commandes

Connectez une scène Unity à un broker MQTT avec MQTTnet, animez une pompe à partir de la télémétrie en direct sur le thread principal, renvoyez des commandes de marche et d'arrêt à l'appareil et passez au TLS pour la production.

Jumeau numérique Unity d'une pompe industrielle qui tourne et change de couleur selon la télémétrie MQTT en direct
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 Unity amène les données réelles d'une machine dans un moteur de jeu, avec à la clé un rendu physique réaliste, la prise en charge de la VR et de l'AR et un éditeur mature pour construire des scènes détaillées. Dans ce tutoriel, vous allez connecter une scène Unity à MQTT avec la bibliothèque MQTTnet, animer une pompe à partir de la télémétrie en direct, renvoyer des commandes de marche et d'arrêt à l'appareil, puis sécuriser la connexion avec TLS. Vous testerez le tout sur le serveur MQTT public gratuit broker.connectmq.com à l'aide d'un petit simulateur Python.

Quand Unity est-il le bon outil pour un jumeau numérique ?

Les moteurs pour navigateur comme Three.js sont le moyen le plus simple de mettre un jumeau sous les yeux de nombreuses personnes. Unity excelle lorsque le jumeau est une application à part entière :

Besoin Unity Navigateur (Three.js / Babylon.js)
Formation des opérateurs, casques VR ou AR Excellent, prise en charge XR intégrée Possible via WebXR, moins d'outillage
Scènes très détaillées, physique, éclairage Excellent Bon, limité par les ressources du navigateur
Accès sans installation pour de nombreux utilisateurs Nécessite un build par plateforme Excellent, une simple URL
MQTT brut sur TCP Oui, sur les builds desktop et mobiles Non, WebSocket uniquement
Compétences de l'équipe C# et l'éditeur Unity JavaScript et outillage web

Si votre jumeau doit s'ouvrir depuis un lien dans n'importe quel navigateur, commencez plutôt par le tutoriel jumeau numérique avec Three.js. S'il s'agit d'une application de salle de contrôle, d'un simulateur de formation ou d'une expérience sur casque, Unity est un excellent choix. Pour les concepts communs aux deux, consultez notre guide de la technologie du jumeau numérique.

Ce que vous allez construire

Une pompe dans une scène Unity qui :

  1. S'abonne à connectmq/demo/unity-7f3a/pump1/telemetry et lit rpm, temperature et running dans le JSON.
  2. Fait tourner sa roue à la vitesse indiquée et fait passer la couleur du corps du bleu au rouge à mesure qu'elle chauffe.
  3. Publie des commandes START et STOP sur connectmq/demo/unity-7f3a/pump1/cmd depuis des boutons d'interface.
  4. Se reconnecte automatiquement en cas de coupure réseau.

Remplacez unity-7f3a par votre propre suffixe aléatoire afin que vos topics soient uniques sur le serveur MQTT public gratuit, qui est partagé.

Étape 1 : ajouter MQTTnet au projet

MQTTnet est un client MQTT .NET open source et mature, qui fonctionne avec le profil .NET Standard 2.1 d'Unity. Ce tutoriel utilise l'API 4.3.x.

  1. Dans Project Settings > Player > Other Settings, réglez Api Compatibility Level sur .NET Standard 2.1.
  2. Téléchargez le paquet MQTTnet 4.3.x depuis NuGet (le fichier .nupkg est une archive zip), extrayez lib/netstandard2.1/MQTTnet.dll et placez-le dans Assets/Plugins/. Vous pouvez aussi l'installer avec le gestionnaire de paquets NuGetForUnity.
  3. Si vous compilez avec IL2CPP (mobile et la plupart des consoles), ajoutez Assets/link.xml pour que le code stripping ne supprime pas les types que MQTTnet charge par réflexion :
<linker>
  <assembly fullname="MQTTnet" preserve="all"/>
</linker>

Notez que MQTTnet 5 a renommé plusieurs types (par exemple, MqttFactory est devenu MqttClientFactory). Si vous mettez à jour plus tard, adaptez les appels à la factory ; la structure générale du script reste identique.

Étape 2 : simuler la pompe

Exécutez ce script Python sur n'importe quel ordinateur pour produire de la télémétrie et réagir aux commandes. Il utilise paho-mqtt 2.x (pip install "paho-mqtt>=2.0").

import json
import math
import time

import paho.mqtt.client as mqtt

PREFIX = "connectmq/demo/unity-7f3a/pump1"
state = {"running": True}


def on_connect(client, userdata, flags, reason_code, properties):
    print("Simulator connected:", reason_code)
    client.subscribe(f"{PREFIX}/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="unity-sim-7f3a")
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()

t = 0.0
temperature = 30.0
while True:
    t += 0.5
    if state["running"]:
        rpm = 1450 + 120 * math.sin(t / 5)
        temperature = min(temperature + 0.4, 85.0)
    else:
        rpm = 0.0
        temperature = max(temperature - 0.8, 25.0)
    payload = {"rpm": round(rpm), "temperature": round(temperature, 1),
               "running": state["running"]}
    client.publish(f"{PREFIX}/telemetry", json.dumps(payload), qos=0)
    time.sleep(0.5)

Deux messages par seconde, c'est bien en deçà de la limite de 10 messages par seconde et par client du serveur MQTT public gratuit.

Étape 3 : le script du jumeau numérique Unity

Créez Assets/Scripts/PumpTwin.cs. Attachez-le à un GameObject vide, puis faites glisser le Transform de votre roue et le Renderer du corps de la pompe dans les champs de l'inspecteur. Un cylindre pour le corps et un cube aplati en guise de pale suffisent pour commencer.

using System;
using System.Collections.Concurrent;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Formatter;
using MQTTnet.Protocol;
using UnityEngine;

[Serializable]
public class PumpTelemetry
{
    public float rpm;
    public float temperature;
    public bool running;
}

public class PumpTwin : MonoBehaviour
{
    [Header("Broker")]
    public string host = "broker.connectmq.com";
    public int port = 1883;
    public bool useTls = false;
    public string username = "";
    public string password = "";
    public string clientId = "";
    public string topicPrefix = "connectmq/demo/unity-7f3a/pump1";

    [Header("Scene")]
    public Transform impeller;
    public Renderer pumpBody;
    public Color coldColor = new Color(0.2f, 0.5f, 0.9f);
    public Color hotColor = new Color(0.9f, 0.2f, 0.15f);
    public Color offlineColor = Color.gray;

    private IMqttClient _client;
    private CancellationTokenSource _cts;
    private readonly ConcurrentQueue<string> _inbox = new ConcurrentQueue<string>();
    private PumpTelemetry _target = new PumpTelemetry { temperature = 20f };
    private float _rpm, _temperature = 20f, _lastMessageTime = -999f;

    private async void Start()
    {
        if (string.IsNullOrEmpty(clientId))
            clientId = "unity-" + Guid.NewGuid().ToString("N").Substring(0, 8);

        _cts = new CancellationTokenSource();
        _client = new MqttFactory().CreateMqttClient();

        // S'exécute sur un thread d'arrière-plan : uniquement mettre en file, ne jamais toucher aux objets Unity ici.
        _client.ApplicationMessageReceivedAsync += e =>
        {
            var seg = e.ApplicationMessage.PayloadSegment;
            if (seg.Count > 0)
                _inbox.Enqueue(Encoding.UTF8.GetString(seg.Array, seg.Offset, seg.Count));
            return Task.CompletedTask;
        };

        _client.DisconnectedAsync += async e =>
        {
            if (_cts.IsCancellationRequested) return;
            Debug.LogWarning($"MQTT disconnected: {e.Reason}. Retrying in 3 s");
            await Task.Delay(3000);
            if (!_cts.IsCancellationRequested) await ConnectAsync();
        };

        await ConnectAsync();
    }

    private async Task ConnectAsync()
    {
        var builder = new MqttClientOptionsBuilder()
            .WithTcpServer(host, port)
            .WithClientId(clientId)
            .WithProtocolVersion(MqttProtocolVersion.V500)
            .WithCleanSession()
            .WithKeepAlivePeriod(TimeSpan.FromSeconds(30));
        if (!string.IsNullOrEmpty(username)) builder.WithCredentials(username, password);
        if (useTls) builder.WithTlsOptions(o => o.UseTls());

        try
        {
            await _client.ConnectAsync(builder.Build(), _cts.Token);
            var subscribe = new MqttFactory().CreateSubscribeOptionsBuilder()
                .WithTopicFilter(f => f
                    .WithTopic($"{topicPrefix}/telemetry")
                    .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce))
                .Build();
            await _client.SubscribeAsync(subscribe, _cts.Token);
            Debug.Log("MQTT connected and subscribed");
        }
        catch (Exception ex)
        {
            Debug.LogWarning($"MQTT connect failed: {ex.Message}");
        }
    }

    private void Update()
    {
        // Vider la file des messages sur le thread principal.
        while (_inbox.TryDequeue(out var json))
        {
            try
            {
                _target = JsonUtility.FromJson<PumpTelemetry>(json);
                _lastMessageTime = Time.time;
            }
            catch (Exception ex)
            {
                Debug.LogWarning($"Bad payload: {ex.Message}");
            }
        }

        float k = 1f - Mathf.Exp(-Time.deltaTime * 4f); // lissage indépendant de la fréquence d'images
        _rpm = Mathf.Lerp(_rpm, _target.rpm, k);
        _temperature = Mathf.Lerp(_temperature, _target.temperature, k);

        if (impeller != null)
            impeller.Rotate(Vector3.up, _rpm / 60f * 360f * Time.deltaTime * 0.1f, Space.Self);

        if (pumpBody != null)
        {
            bool stale = Time.time - _lastMessageTime > 5f;
            float t = Mathf.InverseLerp(25f, 85f, _temperature);
            pumpBody.material.color = stale ? offlineColor : Color.Lerp(coldColor, hotColor, t);
        }
    }

    // Reliez ces méthodes à des boutons d'interface (OnClick) dans l'inspecteur.
    public void SendStart() => _ = PublishCommand("START");
    public void SendStop() => _ = PublishCommand("STOP");

    private async Task PublishCommand(string command)
    {
        if (_client == null || !_client.IsConnected)
        {
            Debug.LogWarning("Not connected, command not sent");
            return;
        }
        var message = new MqttApplicationMessageBuilder()
            .WithTopic($"{topicPrefix}/cmd")
            .WithPayload(command)
            .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
            .Build();
        await _client.PublishAsync(message, _cts.Token);
    }

    private async void OnDestroy()
    {
        _cts?.Cancel();
        if (_client != null && _client.IsConnected)
            await _client.DisconnectAsync();
        _client?.Dispose();
    }
}

Appuyez sur Play pendant que le simulateur tourne. La roue se met à tourner, le corps passe progressivement du bleu au rouge, et un clic sur un bouton Stop relié à SendStop() fait arrêter la pompe par le simulateur ; en moins d'une seconde, le jumeau ralentit la roue et le corps commence à refroidir. Si vous arrêtez complètement le simulateur, le corps devient gris au bout de cinq secondes.

Comment fonctionne l'intégration MQTT dans Unity

Le threading est le piège numéro un. MQTTnet déclenche ApplicationMessageReceivedAsync sur un thread du pool, alors que presque toutes les API Unity (transforms, matériaux, Debug.DrawLine, instanciation) doivent être appelées depuis le thread principal. Les appeler depuis le handler provoque des erreurs ou, pire, des échecs intermittents. Le script contourne le problème en plaçant les payloads bruts dans une ConcurrentQueue et en les traitant dans Update().

Le lissage masque le rythme du réseau. La télémétrie arrive deux fois par seconde, mais Unity affiche 60 images par seconde ou plus. Interpoler vers la dernière valeur cible rend le mouvement continu. Le facteur exponentiel garantit un lissage identique quelle que soit la fréquence d'images.

Les commandes utilisent la QoS 1, la télémétrie la QoS 0. Un échantillon de télémétrie perdu est remplacé une demi-seconde plus tard, alors qu'une commande d'arrêt perdue pose un vrai problème. Lisez les niveaux de QoS MQTT expliqués pour les détails.

L'appareil fait foi. Le jumeau ne modifie jamais son propre état quand vous appuyez sur Stop. Il attend que la pompe indique running: false. Le jumeau reste ainsi fidèle à la réalité si l'appareil rejette ou manque une commande, un principe approfondi dans la synchronisation d'état d'un jumeau numérique.

Faire évoluer le jumeau numérique Unity

Un script par machine suffit pour une démo, mais une usine compte de nombreux équipements. Voici une structure plus propre pour les scènes de grande taille :

  1. Un gestionnaire de connexion unique par application, un singleton qui possède l'IMqttClient, s'abonne avec un caractère générique comme plant1/# et achemine les messages selon leur topic.
  2. Un composant de liaison par équipement (TwinBinding) avec un suffixe de topic et une référence aux parties qu'il pilote. Le gestionnaire retrouve les liaisons dans un dictionnaire indexé par topic.
  3. Des ScriptableObjects pour la configuration, afin que les paramètres du broker et les préfixes de topics diffèrent entre les builds de développement et de production sans modifier le code.
  4. Des messages retenus sur les topics d'état, pour que le jumeau affiche le bon état dès son démarrage.

Associer des champs à des maillages, des matériaux et des animations est le même problème quel que soit le moteur ; lier des données IoT à un modèle 3D détaille les patterns.

Notes par plateforme et dépannage

  • Les builds WebGL ne peuvent pas ouvrir de sockets TCP, et le transport WebSocket de MQTTnet s'appuie sur des API .NET indisponibles dans Unity WebGL. Pour un build navigateur, appelez un client MQTT JavaScript comme MQTT.js via un plugin .jslib, ou construisez la version navigateur avec un moteur web.
  • Android requiert la permission INTERNET, qu'Unity ajoute automatiquement lorsqu'il détecte du code réseau ; vérifiez-la si les connexions échouent silencieusement sur l'appareil.
  • Connect failed: NotAuthorized signifie des identifiants ou un client ID erronés. Connect failed: timeout signifie généralement que le port est bloqué ; testez avec nc -vz broker.connectmq.com 8883.
  • Une boucle de déconnexion toutes les quelques secondes indique que deux instances partagent le même client ID, par exemple l'éditeur et un build autonome. Laissez clientId vide en développement pour en obtenir un aléatoire.

Sécuriser la connexion

Le serveur MQTT public gratuit est anonyme et lisible par tous ; réservez-le donc aux tests et aux prototypes. Pour une vraie usine, créez un broker privé sur un broker MQTT managé et connectez-vous en TLS. Avec ConnectMQ, le portail vous fournit un nom d'utilisateur, un mot de passe et un client ID générés ; dans l'inspecteur, réglez Port sur 8883, cochez Use Tls et renseignez YOUR_USERNAME, YOUR_PASSWORD et YOUR_CLIENT_ID. Le certificat du broker est émis par Let's Encrypt : .NET le valide donc avec le magasin de certificats de confiance du système d'exploitation, sans fichier supplémentaire. Gardez les identifiants hors du contrôle de version en les chargeant à l'exécution depuis un fichier de configuration ou une variable d'environnement.

Démarrer avec ConnectMQ

Un jumeau numérique Unity a besoin d'un broker toujours joignable depuis l'usine et depuis chaque poste opérateur. ConnectMQ fournit exactement cela sous forme de broker MQTT managé : créez un broker gratuit sur app.connectmq.com avec le plan Serverless, sans carte bancaire, avec MQTT 5, TLS et jusqu'à 25 connexions. Son client MQTT intégré au navigateur et son explorateur de topics en direct permettent de vérifier facilement ce que publient vos appareils avant de les lier dans Unity. Vous pouvez aussi continuer à prototyper sur le serveur MQTT public gratuit jusqu'à ce que vous soyez prêt.

Questions fréquentes

Quelle bibliothèque MQTT fonctionne le mieux dans Unity ?

MQTTnet est l'option la plus répandue pour les builds Unity desktop et mobiles, car elle prend en charge MQTT 3.1.1 et 5.0, TLS et des API asynchrones sur .NET Standard 2.1. Pour les builds WebGL, il faut un client JavaScript relié via un plugin.

Pourquoi ai-je des erreurs quand je modifie des objets dans le handler de messages ?

Le handler s'exécute sur un thread d'arrière-plan et la plupart des API Unity ne fonctionnent que sur le thread principal. Mettez les messages en file dans le handler et appliquez-les dans Update().

Un jumeau numérique Unity peut-il piloter de vrais équipements ?

Oui, en publiant des commandes sur des topics auxquels l'appareil est abonné. Utilisez la QoS 1, demandez confirmation pour les actions destructrices dans l'interface et faites republier par l'appareil son état réel, afin que le jumeau reflète ce qui s'est réellement passé.

Est-ce que cela fonctionne avec des casques VR ?

Oui. Le script MQTT est indépendant de la configuration de rendu ; le même composant fonctionne donc dans une scène configurée pour la XR, à condition que la cible de build du casque prenne en charge les API réseau utilisées.

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.