Un gemelo digital en Unity lleva datos reales de máquinas a un motor de videojuegos, donde dispones de renderizado basado en física, soporte para VR y AR y un editor maduro para construir escenas detalladas. En este tutorial conectarás una escena de Unity a MQTT con la librería MQTTnet, animarás una bomba con telemetría en vivo, publicarás comandos de arranque y parada hacia el dispositivo y después protegerás la conexión con TLS. Lo probarás contra el broker público broker.connectmq.com con un pequeño simulador en Python.
Cuándo Unity es la herramienta adecuada para un gemelo digital
Los motores de navegador como Three.js son la forma más sencilla de poner un gemelo delante de mucha gente. Unity destaca cuando el gemelo es una aplicación por derecho propio:
| Necesidad | Unity | Navegador (Three.js / Babylon.js) |
|---|---|---|
| Formación de operarios, visores de VR o AR | Fuerte, soporte XR integrado | Posible mediante WebXR, con menos herramientas |
| Escenas muy detalladas, física, iluminación | Fuerte | Bueno, limitado por los recursos del navegador |
| Acceso sin instalación para muchos usuarios | Requiere una compilación por plataforma | Fuerte, basta una URL |
| MQTT nativo sobre TCP | Sí, en compilaciones de escritorio y móvil | No, solo WebSocket |
| Competencias del equipo | C# y el editor de Unity | JavaScript y herramientas web |
Si tu gemelo debe abrirse desde un enlace en cualquier navegador, empieza mejor por el tutorial de gemelo digital con Three.js o por la guía de gemelo digital 3D basado en web. Si es una aplicación de sala de control, un simulador de formación o una experiencia con visor, Unity es una opción sólida. Para los conceptos que hay detrás de ambos, consulta nuestra guía de tecnología de gemelo digital.
Qué vas a construir
Una bomba en una escena de Unity que:
- Se suscribe a
connectmq/demo/unity-7f3a/pump1/telemetryy leerpm,temperatureyrunningde un JSON. - Hace girar su impulsor a la velocidad indicada y cambia el color del cuerpo de azul a rojo a medida que se calienta.
- Publica comandos
STARTySTOPenconnectmq/demo/unity-7f3a/pump1/cmddesde botones de la interfaz. - Se reconecta automáticamente cuando cae la red.
Sustituye unity-7f3a por tu propio sufijo aleatorio para que tus topics sean únicos en el broker público compartido.
Paso 1: añade MQTTnet al proyecto
MQTTnet es un cliente MQTT para .NET maduro y de código abierto que funciona con el perfil .NET Standard 2.1 de Unity. Este tutorial usa la API 4.3.x.
- En Project Settings > Player > Other Settings, establece Api Compatibility Level en .NET Standard 2.1.
- Descarga el paquete
MQTTnet4.3.x desde NuGet (el archivo.nupkges un zip), extraelib/netstandard2.1/MQTTnet.dlly colócalo enAssets/Plugins/. Como alternativa, instálalo con el gestor de paquetes NuGetForUnity. - Si compilas con IL2CPP (móviles y la mayoría de consolas), añade
Assets/link.xmlpara que el code stripping no elimine tipos que MQTTnet carga por reflexión:
<linker>
<assembly fullname="MQTTnet" preserve="all"/>
</linker>
Ten en cuenta que MQTTnet 5 renombró varios tipos (por ejemplo, MqttFactory pasó a ser MqttClientFactory). Si actualizas más adelante, ajusta las llamadas a la factoría; la estructura general del script se mantiene.
Paso 2: simula la bomba
Ejecuta este script de Python en cualquier ordenador para generar telemetría y reaccionar a los comandos. Usa 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)
Dos mensajes por segundo quedan muy por debajo del límite del broker público de 10 mensajes por segundo por cliente.
Paso 3: el script del gemelo digital en Unity
Crea Assets/Scripts/PumpTwin.cs. Asígnalo a un GameObject vacío y arrastra el Transform del impulsor y el Renderer del cuerpo de la bomba a los campos del inspector. Para empezar basta con un cilindro como cuerpo y un cubo aplanado como pala del impulsor.
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();
// Se ejecuta en un hilo en segundo plano: solo encolar, nunca tocar objetos de Unity aquí.
_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()
{
// Vaciar la cola de mensajes en el hilo 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); // suavizado independiente de la tasa de fotogramas
_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);
}
}
// Conecta estos métodos a botones de la UI (OnClick) en el inspector.
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();
}
}
Pulsa Play con el simulador en marcha. El impulsor acelera, el cuerpo pasa de azul a rojo al calentarse y, al hacer clic en un botón Stop conectado a SendStop(), el simulador detiene la bomba; en menos de un segundo el gemelo frena el impulsor y el cuerpo empieza a enfriarse. Si detienes por completo el simulador, el cuerpo se vuelve gris al cabo de cinco segundos.
Cómo funciona la integración de Unity con MQTT
Los hilos son el error número uno. MQTTnet lanza ApplicationMessageReceivedAsync en un hilo del thread pool, pero casi todas las API de Unity (transforms, materiales, Debug.DrawLine, instanciación) deben llamarse desde el hilo principal. Llamarlas desde el manejador provoca errores o, peor aún, fallos intermitentes. El script lo evita metiendo los payloads en bruto en una ConcurrentQueue y procesándolos en Update().
El suavizado oculta el ritmo de la red. La telemetría llega dos veces por segundo, pero Unity renderiza 60 fotogramas por segundo o más. Interpolar hacia el último objetivo hace que el movimiento sea continuo. El factor exponencial mantiene el suavizado idéntico sea cual sea la tasa de fotogramas.
Los comandos usan QoS 1 y la telemetría QoS 0. Una muestra de telemetría perdida se sustituye medio segundo después, mientras que un comando de parada perdido es un problema real. Lee los niveles de QoS de MQTT explicados para conocer los detalles.
El dispositivo es la fuente de verdad. El gemelo nunca cambia su propio estado cuando pulsas Stop. Espera a que la bomba informe running: false. Así el gemelo sigue siendo fiel si el dispositivo rechaza o no recibe un comando, un principio que se desarrolla en sincronización de estado del gemelo digital.
Escalar el gemelo digital en Unity
Un script por máquina sirve para una demo, pero una planta tiene muchos activos. Una estructura más limpia para escenas grandes:
- Un gestor de conexión por aplicación, un singleton que posee el
IMqttClient, se suscribe con un comodín comoplant1/#y enruta los mensajes por topic. - Un componente de vinculación por activo (
TwinBinding) con un sufijo de topic y una referencia a las partes que controla. El gestor busca las vinculaciones en un diccionario indexado por topic. - ScriptableObjects para la configuración, de modo que los ajustes del broker y los prefijos de topic difieran entre las compilaciones de desarrollo y producción sin cambiar código.
- Retained messages en los topics de estado, para que el gemelo muestre el estado correcto en cuanto arranca.
Mapear campos a mallas, materiales y animaciones es el mismo problema sea cual sea el motor; vincular datos IoT a un modelo 3D cubre los patrones en detalle.
Notas por plataforma y resolución de problemas
- Las compilaciones WebGL no pueden abrir sockets TCP, y el transporte WebSocket de MQTTnet depende de API de .NET que no están disponibles en Unity WebGL. Para una compilación de navegador, llama a un cliente MQTT de JavaScript como MQTT.js mediante un plugin
.jslib, o crea la versión de navegador con un motor web. - Android necesita el permiso INTERNET, que Unity añade automáticamente cuando detecta código de red; compruébalo si las conexiones fallan sin aviso en el dispositivo.
- Connect failed: NotAuthorized indica credenciales o client ID incorrectos. Connect failed: timeout suele significar que el puerto está bloqueado; pruébalo con
nc -vz broker.connectmq.com 8883. - Un bucle de desconexiones cada pocos segundos indica que dos instancias comparten client ID, por ejemplo el editor y una compilación independiente. Deja
clientIdvacío en desarrollo para obtener uno aleatorio.
Proteger la conexión
El broker público es anónimo y cualquiera puede leerlo, así que úsalo solo para pruebas. Para una planta real, crea un broker privado con un servicio de broker MQTT gestionado y conéctate por TLS. Con ConnectMQ, el portal te da un nombre de usuario, una contraseña y un client ID generados; en el inspector establece Port en 8883, marca Use Tls y rellena YOUR_USERNAME, YOUR_PASSWORD y YOUR_CLIENT_ID. El certificado del broker lo emite Let's Encrypt, así que .NET lo valida contra el almacén de confianza del sistema operativo sin archivos adicionales. Mantén las credenciales fuera del control de versiones cargándolas en tiempo de ejecución desde un archivo de configuración o una variable de entorno.
Empieza con ConnectMQ
Un gemelo digital en Unity necesita un broker siempre accesible desde la planta y desde cada puesto de operador. ConnectMQ ofrece exactamente eso como servicio alojado: crea un broker gratuito en app.connectmq.com con el plan Serverless, sin tarjeta de crédito, con MQTT 5, TLS y hasta 25 conexiones. Su cliente MQTT en el navegador y su explorador de topics en vivo facilitan comprobar lo que publican tus dispositivos antes de vincularlo en Unity. También puedes seguir prototipando con datos de prueba en el servidor MQTT público gratuito hasta que estés listo.
Preguntas frecuentes
¿Qué librería MQTT funciona mejor en Unity?
MQTTnet es la opción más utilizada para compilaciones de Unity de escritorio y móvil porque admite MQTT 3.1.1 y 5.0, TLS y API asíncronas sobre .NET Standard 2.1. Para compilaciones WebGL necesitas un cliente JavaScript conectado mediante un plugin.
¿Por qué obtengo errores al actualizar objetos en el manejador de mensajes?
El manejador se ejecuta en un hilo en segundo plano y la mayoría de las API de Unity solo funcionan en el hilo principal. Encola los mensajes en el manejador y aplícalos en Update().
¿Puede un gemelo digital en Unity controlar equipos reales?
Sí, publicando comandos en topics a los que se suscribe el dispositivo. Usa QoS 1, confirma las acciones destructivas en la interfaz y haz que el dispositivo publique de vuelta su estado real para que el gemelo refleje lo que ocurrió de verdad.
¿Funciona con visores de realidad virtual?
Sí. El script MQTT es independiente de la configuración de renderizado, por lo que el mismo componente funciona en una escena configurada para XR, siempre que el target de compilación del visor admita las API de red utilizadas.



