Tutoriales de IoT

Cliente MQTT en Unity: controla escenas 3D con datos de sensores en vivo

Añade un cliente MQTT a Unity con MQTTnet, suscríbete a topics de sensores en broker.connectmq.com y anima GameObjects con datos en vivo de forma segura en el hilo principal, la base de un gemelo digital.

Escena del editor de Unity con un ventilador girando y un tanque con código de colores controlados por un cliente MQTT en Unity
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Un cliente MQTT en Unity convierte una escena 3D estática en una vista en vivo de equipos reales: un ventilador gira a las RPM medidas, un tanque cambia de color con la temperatura, una puerta se abre cuando un sensor lo indica. En este tutorial añadirás MQTTnet a un proyecto de Unity, te suscribirás a topics de sensores en broker.connectmq.com y actualizarás GameObjects de forma segura desde el hilo principal de Unity. Es la misma base que usa un gemelo digital 3D, ya sea en el navegador o en el escritorio.

Cómo llegan los datos en vivo a una escena de Unity

El flujo tiene tres partes, y la del medio es donde fallan la mayoría de los primeros intentos:

Sensor or simulator --MQTT--> Broker --MQTT--> MQTTnet (background thread)
                                                  |
                                       ConcurrentDictionary (latest value per topic)
                                                  |
                                       MonoBehaviour.Update() (main thread) --> Transforms, materials

La API de Unity no es thread-safe. Los callbacks de red de MQTTnet se ejecutan en hilos del thread pool, y tocar un Transform o un Material desde ahí lanza UnityException: ... can only be called from the main thread o, peor aún, falla en silencio. La solución es guardar los valores entrantes en una estructura thread-safe y aplicarlos en Update().

Para la visualización, normalmente solo te interesa el último valor de cada topic, no todos los mensajes. Un diccionario indexado por topic te da gratis la semántica de «gana el último valor» y mantiene constante el tiempo de fotograma aunque un sensor publique más rápido que tu tasa de fotogramas.

Añadir MQTTnet a Unity

El runtime de scripting de Unity apunta a .NET Standard 2.1 (Project Settings > Player > Api Compatibility Level). Usa una versión 4.x de MQTTnet, que incluye una compilación para .NET Standard; las versiones mayores más recientes pueden apuntar solo a .NET moderno y no cargarán en Unity.

Dos formas de instalarlo:

  1. NuGetForUnity: instala el paquete, abre NuGet > Manage NuGet Packages, busca MQTTnet y elige una versión 4.x.
  2. Manual: descarga el .nupkg de nuget.org, cámbiale el nombre a .zip, extrae lib/netstandard2.1/MQTTnet.dll y colócalo en Assets/Plugins/.

Si compilas con IL2CPP (obligatorio para iOS y habitual en Android y consolas), añade un link.xml en Assets/ para que el optimizador de código no elimine partes de MQTTnet que se usan mediante reflexión:

<linker>
  <assembly fullname="MQTTnet" preserve="all" />
</linker>

El script gestor de MQTT

Crea Assets/Scripts/MqttManager.cs. Se conecta por TLS, se suscribe a un prefijo de topic, guarda el último payload de cada topic y se reconecta con 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()
    {
        // Las API de Unity como Application deben leerse en el hilo principal
        _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();
    }
}

Notas sobre las decisiones tomadas:

  • QoS 0 es lo adecuado para datos de visualización de alta frecuencia. Si se pierde el valor de un fotograma, el siguiente lo sustituye un instante después. Usa QoS 1 para comandos y alarmas.
  • El client ID se construye en Start(), en el hilo principal, porque ConnectionLoop se ejecuta en un hilo del thread pool donde no deben llamarse API de Unity como Application y SystemInfo. El sufijo aleatorio evita que dos instancias del editor o dos dispositivos se expulsen mutuamente del broker.
  • DontDestroyOnLoad mantiene una única conexión entre cargas de escena en lugar de reconectar cada vez.
  • Desconectar en OnDestroy importa en el editor: sin ello, cada ciclo de Play/Stop deja una conexión fantasma hasta que expira el keep-alive, y en un broker con cuota de conexiones puedes agotarla rápidamente.

Vincular topics a GameObjects

Ahora viene lo divertido. Un componente reutilizable que hace girar un transform a partir de 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;
        }
        // Suaviza hacia el objetivo para que los saltos entre mensajes parezcan naturales
        _currentRpm = Mathf.Lerp(_currentRpm, _targetRpm, Time.deltaTime * 2f);
        transform.Rotate(axis, _currentRpm * 6f * Time.deltaTime); // rpm * 360 / 60
    }
}

Y otro que colorea un renderer a partir de un payload JSON de temperatura:

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; // nada nuevo
        _lastRaw = raw;
        var reading = JsonUtility.FromJson<Reading>(raw);
        var t = Mathf.InverseLerp(min, max, reading.temperature);
        _renderer.material.color = gradient.Evaluate(t);
    }
}

Dos detalles que conviene copiar: analiza los números con CultureInfo.InvariantCulture (de lo contrario, 12.5 falla en equipos que usan la coma como separador decimal) y evita trabajo cuando el payload no ha cambiado. JsonUtility es rápido y apenas reserva memoria, pero solo gestiona campos de clases serializables; para JSON anidado o dinámico, Newtonsoft Json (disponible como paquete de Unity) es más flexible.

Aliméntalo con datos de prueba

Crea un GameObject vacío con MqttManager, un cilindro con SpinFromTopic y una cápsula con ColorFromTemperature. Pulsa Play y simula los sensores desde una terminal:

# Gira el ventilador a 120 RPM y luego a 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

# Calienta el tanque en un bucle (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

El ventilador debería acelerar suavemente y el tanque debería desplazarse a lo largo de tu degradado. El servidor MQTT público gratuito permite 10 mensajes por segundo por cliente, así que mantén los simuladores por debajo de esa cifra y recuerda que el área pública es visible para todo el mundo: úsala para pruebas y prototipos, nunca para datos de producción. Encontrarás más trucos de publicación en los ejemplos de mosquitto_pub y mosquitto_sub.

Enviar comandos desde la escena

Un gemelo se convierte en algo más que un visor cuando los usuarios pueden actuar sobre él. Conecta un botón de la interfaz o un clic sobre un objeto 3D a MqttManager.Instance.PublishAsync("pump1/cmd", "{\"action\":\"stop\"}"), y haz que el dispositivo real (o tu simulador) lo confirme publicando su nuevo estado. Anima a partir del estado confirmado, no del clic, para que la escena nunca muestre algo que el equipo no hizo. Para los comandos, usa QoS 1 y plantéate una expiración del mensaje para que un dispositivo que se reconecte mucho más tarde no ejecute una instrucción obsoleta.

Notas por plataforma para un cliente MQTT en Unity

Destino ¿Funciona con MQTTnet sobre TCP/TLS? Notas
Windows, macOS, Linux standalone Sí El destino más sencillo; prueba aquí primero
Android Sí IL2CPP necesita link.xml; permite el acceso a internet en Player settings
iOS Sí IL2CPP y link.xml; prefiere TLS en el 8883
WebGL No Los navegadores no permiten sockets TCP directos. Usa MQTT sobre WebSocket mediante un puente JavaScript (un .jslib que envuelve una biblioteca MQTT de navegador) hacia wss://broker.connectmq.com:8084/mqtt
Visores de RV (basados en Android) Sí Igual que Android; vigila el tiempo de fotograma y mantén los handlers mínimos

WebGL es el que sorprende a la gente. Si tu objetivo final es un gemelo en el navegador, valora si te encaja mejor un stack 3D web; la guía de MQTT sobre WebSocket explica el lado del navegador.

De la escena en vivo al gemelo digital

Animar objetos a partir de topics es el primer paso. Un gemelo útil también necesita una estructura de topics coherente, estado retenido para que la escena sea correcta en cuanto se carga y una forma de enviar comandos de vuelta. La propia demo web de gemelo digital 3D de ConnectMQ, una fábrica con una máquina CNC, un brazo robótico, un horno, un tanque, una cinta transportadora y una bomba, sigue exactamente este enfoque con datos en vivo sobre WebSocket seguro. Organizar los topics con los niveles de ISA-95 (empresa/planta/área/línea/celda) también facilita mapear los topics a la jerarquía de la escena; lee MQTT Sparkplug B y Unified Namespace para conocer esa estructura. Si además creas herramientas .NET alrededor de la escena, el tutorial de MQTTnet cubre con más profundidad las propiedades de MQTT 5 y TLS.

Llevarlo a producción

Para cualquier cosa que vaya más allá de una demo, conecta Unity a un broker privado. Un broker MQTT gestionado como ConnectMQ te da TLS con un certificado de Let's Encrypt, MQTT 5 y credenciales generadas; rellena host, username y password en el inspector (o, mejor aún, cárgalos en tiempo de ejecución desde un archivo de configuración fuera del build). Siempre que puedas, no des permisos de publicación a las aplicaciones de visualización, para que un build descompilado no pueda inyectar datos de sensores falsos. Si estás decidiendo dónde debe residir el broker, la explicación de qué hace un broker MQTT y la guía de bibliotecas cliente MQTT te ayudan a plantear las opciones.

Empieza con ConnectMQ

Apunta tu escena de Unity a un broker privado en minutos: regístrate en app.connectmq.com con el plan Serverless gratuito, sin tarjeta de crédito, y usa el explorador de topics en vivo del portal para ver exactamente a qué está suscrita tu escena mientras la construyes.

Preguntas frecuentes

¿Qué biblioteca MQTT debo usar en Unity?

MQTTnet 4.x es una opción sólida porque se usa de forma activa, admite MQTT 5 y TLS e incluye una compilación para .NET Standard compatible con Unity. Evita las bibliotecas que bloquean el hilo principal o que dependen de API que Unity no admite.

¿Por qué mi cliente MQTT en Unity lanza «can only be called from the main thread»?

Estás tocando objetos de Unity dentro del handler de mensajes. Guarda el payload en una colección thread-safe y aplícalo en Update(), como se muestra en este tutorial.

¿Puede un build WebGL de Unity conectarse a MQTT?

No con sockets TCP. Un build WebGL debe usar MQTT sobre WebSocket seguro mediante un plugin de JavaScript, conectándose a un endpoint WSS como el puerto 8084 con la ruta /mqtt.

¿Cuántos mensajes por segundo puede manejar una escena de Unity?

El límite suele estar en tu procesamiento por mensaje, no en MQTT. Con el patrón de último valor por topic, la escena hace un trabajo constante por fotograma independientemente de la tasa de mensajes, así que el plan del broker y la red se convierten en los límites prácticos.

Artículos relacionados

Las mejores herramientas CLI de MQTT para pruebas, scripts y depuración

Una comparativa práctica de mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, la CLI de MQTT.js, curl y mqttui, con la misma prueba ejecutada contra broker.connectmq.com para que elijas la herramienta adecuada para scripts y depuración.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.