Un client MQTT Unity transforme une scène 3D statique en vue vivante d'équipements réels : un ventilateur tourne à la vitesse mesurée, une cuve change de couleur selon la température, une porte s'ouvre quand un capteur l'indique. Dans ce tutoriel, vous allez ajouter MQTTnet à un projet Unity, vous abonner à des topics de capteurs sur le serveur MQTT public gratuit broker.connectmq.com et mettre à jour des GameObjects en toute sécurité depuis le thread principal de Unity. C'est la même base que celle des jumeaux numériques dans le navigateur ou sur desktop.
Comment les données en direct atteignent une scène Unity
Le flux comporte trois parties, et c'est celle du milieu qui fait échouer la plupart des premières tentatives :
Sensor or simulator --MQTT--> Broker --MQTT--> MQTTnet (background thread)
|
ConcurrentDictionary (latest value per topic)
|
MonoBehaviour.Update() (main thread) --> Transforms, materials
L'API de Unity n'est pas thread-safe. Les callbacks réseau de MQTTnet s'exécutent sur des threads du pool, et manipuler un Transform ou un Material depuis ces threads lève UnityException: ... can only be called from the main thread, ou pire, échoue en silence. La solution consiste à stocker les valeurs entrantes dans une structure thread-safe et à les appliquer dans Update().
Pour la visualisation, seule compte généralement la dernière valeur de chaque topic, pas chaque message. Un dictionnaire indexé par topic vous donne gratuitement le comportement « la dernière valeur l'emporte » et maintient un temps de frame constant, même si un capteur publie plus vite que votre fréquence d'images.
Ajouter MQTTnet à Unity
Le runtime de scripting de Unity cible .NET Standard 2.1 (Project Settings > Player > Api Compatibility Level). Utilisez une version 4.x de MQTTnet, qui fournit un build .NET Standard ; les versions majeures plus récentes peuvent ne cibler que le .NET moderne et ne se chargeront pas dans Unity.
Deux façons de l'installer :
- NuGetForUnity : installez le package, ouvrez NuGet > Manage NuGet Packages, recherchez MQTTnet et choisissez une version 4.x.
- Manuellement : téléchargez le
.nupkgdepuis nuget.org, renommez-le en.zip, extrayezlib/netstandard2.1/MQTTnet.dllet déposez-le dansAssets/Plugins/.
Si vous compilez avec IL2CPP (obligatoire pour iOS et courant pour Android et les consoles), ajoutez un link.xml dans Assets/ pour que le stripping de code ne supprime pas des parties de MQTTnet utilisées par réflexion :
<linker>
<assembly fullname="MQTTnet" preserve="all" />
</linker>
Le script MQTT Manager
Créez Assets/Scripts/MqttManager.cs. Il se connecte en TLS, s'abonne à un préfixe de topic, conserve le dernier payload de chaque topic et se reconnecte avec backoff.
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using MQTTnet;
using MQTTnet.Client;
using MQTTnet.Protocol;
using UnityEngine;
public class MqttManager : MonoBehaviour
{
public static MqttManager Instance { get; private set; }
[SerializeField] private string host = "broker.connectmq.com";
[SerializeField] private int port = 8883;
[SerializeField] private bool useTls = true;
[SerializeField] private string topicPrefix = "connectmq/demo/u9n1ty";
[SerializeField] private string username = "";
[SerializeField] private string password = "";
private readonly ConcurrentDictionary<string, string> _latest = new();
private IMqttClient _client;
private CancellationTokenSource _cts;
private string _clientId;
public bool IsConnected => _client != null && _client.IsConnected;
public bool TryGet(string relativeTopic, out string payload) =>
_latest.TryGetValue($"{topicPrefix}/{relativeTopic}", out payload);
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void Start()
{
// Unity APIs such as Application must be read on the main thread
_clientId = $"unity-{Application.platform}-{Guid.NewGuid().ToString("N").Substring(0, 8)}";
_cts = new CancellationTokenSource();
_client = new MqttFactory().CreateMqttClient();
_client.ApplicationMessageReceivedAsync += e =>
{
_latest[e.ApplicationMessage.Topic] = e.ApplicationMessage.ConvertPayloadToString();
return Task.CompletedTask;
};
_ = Task.Run(() => ConnectionLoop(_cts.Token));
}
private async Task ConnectionLoop(CancellationToken ct)
{
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(host, port)
.WithClientId(_clientId)
.WithProtocolVersion(MQTTnet.Formatter.MqttProtocolVersion.V500)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30));
if (useTls) builder.WithTlsOptions(o => o.UseTls());
if (!string.IsNullOrEmpty(username)) builder.WithCredentials(username, password);
var options = builder.Build();
var delay = 1;
while (!ct.IsCancellationRequested)
{
try
{
if (!_client.IsConnected)
{
await _client.ConnectAsync(options, ct);
await _client.SubscribeAsync(new MqttClientSubscribeOptionsBuilder()
.WithTopicFilter(f => f.WithTopic($"{topicPrefix}/#")
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce))
.Build(), ct);
Debug.Log("MQTT connected");
delay = 1;
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
Debug.LogWarning($"MQTT connect failed: {ex.Message}");
delay = Math.Min(delay * 2, 30);
}
try { await Task.Delay(TimeSpan.FromSeconds(delay), ct); }
catch (OperationCanceledException) { break; }
}
}
public Task PublishAsync(string relativeTopic, string payload, bool retain = false)
{
if (!IsConnected) return Task.CompletedTask;
var msg = new MqttApplicationMessageBuilder()
.WithTopic($"{topicPrefix}/{relativeTopic}")
.WithPayload(payload)
.WithRetainFlag(retain)
.Build();
return _client.PublishAsync(msg);
}
private async void OnDestroy()
{
if (Instance != this) return;
_cts?.Cancel();
if (_client != null && _client.IsConnected)
{
try { await _client.DisconnectAsync(); } catch { }
}
_client?.Dispose();
}
}
Remarques sur ces choix :
- QoS 0 convient aux données de visualisation à haut débit. Si la valeur d'une frame est perdue, la suivante la remplace un instant plus tard. Utilisez QoS 1 pour les commandes et les alarmes.
- Le client ID est construit dans
Start(), sur le thread principal, carConnectionLoops'exécute sur un thread du pool où les API Unity commeApplicationetSystemInfone doivent pas être appelées. Le suffixe aléatoire évite que deux instances de l'éditeur ou deux appareils ne s'expulsent mutuellement du broker. DontDestroyOnLoadconserve une seule connexion d'un chargement de scène à l'autre au lieu de se reconnecter à chaque fois.- La déconnexion dans
OnDestroyest importante dans l'éditeur : sans elle, chaque cycle Play/Stop laisse une connexion fantôme jusqu'à l'expiration du keep-alive, et sur un broker avec un quota de connexions, vous pouvez vite être à court.
Lier les topics aux GameObjects
Passons à la partie amusante. Un composant réutilisable qui fait tourner un transform à partir d'un topic de RPM :
using System.Globalization;
using UnityEngine;
public class SpinFromTopic : MonoBehaviour
{
[SerializeField] private string topic = "fan1/rpm";
[SerializeField] private Vector3 axis = Vector3.up;
private float _targetRpm, _currentRpm;
private void Update()
{
if (MqttManager.Instance != null &&
MqttManager.Instance.TryGet(topic, out var raw) &&
float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var rpm))
{
_targetRpm = rpm;
}
// Smooth toward the target so jumps between messages look natural
_currentRpm = Mathf.Lerp(_currentRpm, _targetRpm, Time.deltaTime * 2f);
transform.Rotate(axis, _currentRpm * 6f * Time.deltaTime); // rpm * 360 / 60
}
}
Et un autre qui colore un renderer à partir d'un payload JSON de température :
using UnityEngine;
public class ColorFromTemperature : MonoBehaviour
{
[System.Serializable] private class Reading { public float temperature; }
[SerializeField] private string topic = "tank1/telemetry";
[SerializeField] private float min = 20f, max = 90f;
[SerializeField] private Gradient gradient;
private Renderer _renderer;
private string _lastRaw;
private void Awake() => _renderer = GetComponent<Renderer>();
private void Update()
{
if (MqttManager.Instance == null || !MqttManager.Instance.TryGet(topic, out var raw)) return;
if (raw == _lastRaw) return; // nothing new
_lastRaw = raw;
var reading = JsonUtility.FromJson<Reading>(raw);
var t = Mathf.InverseLerp(min, max, reading.temperature);
_renderer.material.color = gradient.Evaluate(t);
}
}
Deux détails à reprendre : analysez les nombres avec CultureInfo.InvariantCulture (sinon 12.5 échoue sur les machines qui utilisent la virgule comme séparateur décimal), et évitez tout traitement lorsque le payload n'a pas changé. JsonUtility est rapide et peu gourmand en allocations, mais ne gère que les champs de classes sérialisables ; pour du JSON imbriqué ou dynamique, Newtonsoft Json (disponible en package Unity) est plus souple.
L'alimenter avec des données de test
Créez un GameObject vide avec MqttManager, un cylindre avec SpinFromTopic et une capsule avec ColorFromTemperature. Appuyez sur Play, puis simulez des capteurs depuis un terminal :
# Spin the fan at 120 RPM, then 600 RPM
mosquitto_pub -h broker.connectmq.com -p 1883 -i unity-sim-1 -t connectmq/demo/u9n1ty/fan1/rpm -m 120
mosquitto_pub -h broker.connectmq.com -p 1883 -i unity-sim-1 -t connectmq/demo/u9n1ty/fan1/rpm -m 600
# Heat the tank in a loop (bash)
for t in $(seq 20 5 90); do
mosquitto_pub -h broker.connectmq.com -p 1883 -i unity-sim-2 \
-t connectmq/demo/u9n1ty/tank1/telemetry -m "{\"temperature\": $t}"
sleep 0.5
done
Le ventilateur devrait monter progressivement en vitesse et la cuve changer de couleur le long de votre dégradé. Le serveur MQTT public gratuit autorise 10 messages par seconde par client : restez donc en dessous avec vos simulateurs, et n'oubliez pas que ce serveur de test est visible par tous. D'autres astuces de publication figurent dans les exemples mosquitto_pub et mosquitto_sub.
Envoyer des commandes depuis la scène
Un jumeau devient plus qu'une simple visionneuse lorsque les utilisateurs peuvent agir dessus. Reliez un bouton d'interface ou un clic sur un objet 3D à MqttManager.Instance.PublishAsync("pump1/cmd", "{\"action\":\"stop\"}"), et faites confirmer l'appareil réel (ou votre simulateur) en publiant son nouvel état. Animez à partir de l'état confirmé, pas du clic, pour que la scène ne montre jamais une action que l'équipement n'a pas réalisée. Pour les commandes, utilisez QoS 1 et envisagez une expiration de message afin qu'un appareil qui se reconnecte bien plus tard n'exécute pas une instruction périmée.
Notes par plateforme pour un client MQTT Unity
| Cible | Fonctionne avec MQTTnet en TCP/TLS ? | Remarques |
|---|---|---|
| Windows, macOS, Linux standalone | Oui | Cible la plus simple ; testez d'abord ici |
| Android | Oui | IL2CPP nécessite link.xml ; autorisez l'accès Internet dans les Player settings |
| iOS | Oui | IL2CPP et link.xml ; préférez TLS sur 8883 |
| WebGL | Non | Les navigateurs n'autorisent pas les sockets TCP bruts. Utilisez MQTT over WebSocket via un pont JavaScript (un .jslib encapsulant une bibliothèque MQTT pour navigateur) vers wss://broker.connectmq.com:8084/mqtt |
| Casques VR (basés sur Android) | Oui | Comme Android ; surveillez le temps de frame et gardez des handlers minimalistes |
WebGL est la cible qui surprend. Si votre objectif final est un jumeau dans le navigateur, demandez-vous si une stack 3D web ne serait pas plus adaptée ; le guide MQTT over WebSocket explique le côté navigateur.
De la scène en direct au jumeau numérique
Animer des objets à partir de topics n'est que la première étape. Un jumeau utile a aussi besoin d'une structure de topics cohérente, d'un état retained pour que la scène soit correcte dès son chargement, et d'un moyen de renvoyer des commandes. La démo web de jumeau numérique 3D de ConnectMQ, une usine avec une machine CNC, un bras robotisé, un four, une cuve, un convoyeur et une pompe, suit exactement cette approche avec des données en direct sur WebSocket sécurisé. Organiser les topics selon les niveaux ISA-95 (entreprise/site/zone/ligne/cellule) facilite aussi la correspondance entre topics et hiérarchie de la scène ; lisez MQTT Sparkplug B et Unified Namespace pour cette structure. Si vous développez aussi des outils .NET autour de la scène, le tutoriel MQTTnet détaille les propriétés MQTT 5 et TLS.
Passer en production
Au-delà d'une démo, connectez Unity à un broker privé. Un broker MQTT managé comme ConnectMQ vous offre TLS avec un certificat Let's Encrypt, MQTT 5 et des identifiants générés ; renseignez host, username et password dans l'inspecteur (ou mieux, chargez-les à l'exécution depuis un fichier de configuration extérieur au build). Retirez autant que possible les droits de publication aux applications de visualisation, afin qu'un build décompilé ne puisse pas injecter de fausses données de capteurs. Si vous hésitez sur l'emplacement du broker, la présentation de ce que fait un broker MQTT et le guide des bibliothèques client MQTT aident à cadrer les options.
Démarrer avec ConnectMQ
Connectez votre scène Unity à un broker privé en quelques minutes : inscrivez-vous sur app.connectmq.com avec le plan Serverless gratuit, sans carte bancaire, et utilisez l'explorateur de topics en direct du portail pour voir exactement à quoi votre scène est abonnée pendant que vous la construisez.
Questions fréquentes
Quelle bibliothèque MQTT utiliser dans Unity ?
MQTTnet 4.x est un choix solide : elle est activement utilisée, prend en charge MQTT 5 et TLS, et fournit un build .NET Standard compatible avec Unity. Évitez les bibliothèques qui bloquent le thread principal ou dépendent d'API que Unity ne prend pas en charge.
Pourquoi mon client MQTT Unity lève-t-il « can only be called from the main thread » ?
Vous manipulez des objets Unity dans le handler de messages. Stockez le payload dans une collection thread-safe et appliquez-le dans Update(), comme le montre ce tutoriel.
Un build Unity WebGL peut-il se connecter à MQTT ?
Pas avec des sockets TCP. Un build WebGL doit utiliser MQTT over WebSocket sécurisé via un plugin JavaScript, en se connectant à un endpoint WSS comme le port 8084 avec le chemin /mqtt.
Combien de messages par seconde une scène Unity peut-elle gérer ?
La limite tient généralement à votre traitement par message, pas à MQTT. Avec le modèle « dernière valeur par topic », la scène effectue un travail constant par frame quel que soit le débit de messages : ce sont alors le plan du broker et le réseau qui constituent les limites pratiques.



