Gemelo digital

Gemelo digital con Babylon.js y datos IoT en tiempo real por MQTT

Construye un gemelo digital interactivo con Babylon.js de un skid de refrigeración que lee telemetría MQTT en vivo por WebSocket seguro, resalta alarmas, mueve la aguja de un manómetro y envía comandos de arranque y parada al hacer clic en la bomba.

Gemelo digital con Babylon.js de un skid de refrigeración con una bomba resaltada y un manómetro controlados por datos MQTT en vivo
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 gemelo digital con Babylon.js te ofrece un motor 3D completo en el navegador para construir un gemelo digital 3D, con selección de objetos (picking), efectos de resaltado e inspector integrados, lo que permite construir gemelos interactivos con rapidez. En este tutorial crearás un pequeño skid de refrigeración (una bomba, un tanque y un manómetro) que reacciona a telemetría MQTT en vivo a través de WebSocket seguro, se ilumina en rojo cuando la presión es alta y te permite arrancar o parar la bomba haciendo clic sobre ella. Todos los datos pasan por el servidor MQTT público gratuito broker.connectmq.com, pensado para pruebas y prototipos, y un simulador en Python hace el papel del equipo real.

Por qué elegir Babylon.js para un gemelo digital

Tanto Babylon.js como Three.js renderizan escenas WebGL en el navegador y ambos funcionan bien con MQTT. La diferencia está en lo que viene incluido:

Característica Babylon.js Three.js
Picking y acciones de puntero Integrado (ActionManager, scene.pick) Raycaster, conectas los eventos tú mismo
Efectos de brillo y resaltado HighlightLayer, GlowLayer integrados Pases de posprocesado que hay que montar
Inspector de depuración scene.debugLayer.show() Herramientas de terceros
Tamaño del bundle y estilo de API Mayor, más funciones incluidas Menor, más modular
Carga de glTF Paquete de loaders Complemento GLTFLoader

Si prefieres una librería más ligera, nuestro tutorial de gemelo digital con Three.js construye una escena similar. Si el gemelo gira sobre todo en torno a la interacción (hacer clic en una máquina, ver detalles, enviar un comando), Babylon.js te ahorra mucha fontanería. Los conceptos generales se tratan en nuestra guía de tecnología de gemelo digital.

Topics y payloads

El gemelo usa tres topics bajo un prefijo único. Sustituye bjs-7f3a por tu propio sufijo aleatorio:

Topic Publicador Payload Comportamiento del gemelo
connectmq/demo/bjs-7f3a/skid1/telemetry Equipo {"pressure":3.2,"level":0.55,"running":true} Aguja del manómetro, nivel del tanque, giro de la bomba
connectmq/demo/bjs-7f3a/skid1/cmd Gemelo (al hacer clic) START o STOP El simulador cambia el estado de la bomba
connectmq/demo/bjs-7f3a/skid1/status Equipo, retenido, last will online / offline Se vuelve gris cuando está desconectado

La presión va en bar y el nivel es una fracción de 0 a 1. Mantener las unidades fijas y documentadas evita el error más común en un gemelo: un manómetro que muestra kilopascales como si fueran bar.

Paso 1: el simulador del equipo

Instala paho-mqtt 2.x (pip install "paho-mqtt>=2.0") y ejecuta este script. Simula una presión que sube lentamente mientras la bomba funciona, con picos ocasionales por encima del límite de alarma de 6 bar, y obedece los comandos del gemelo.

import json
import math
import random
import time

import paho.mqtt.client as mqtt

P = "connectmq/demo/bjs-7f3a/skid1"
state = {"running": True}


def on_connect(client, userdata, flags, reason_code, properties):
    print("connected:", reason_code)
    client.publish(f"{P}/status", "online", qos=1, retain=True)
    client.subscribe(f"{P}/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="bjs-sim-7f3a")
client.will_set(f"{P}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()

t, level = 0, 0.5
while True:
    t += 1
    if state["running"]:
        pressure = 4.0 + 1.2 * math.sin(t / 10) + (2.5 if random.random() < 0.05 else 0)
        level = max(0.1, level - 0.01)
    else:
        pressure = max(0.2, 1.0 + random.uniform(-0.1, 0.1))
        level = min(0.95, level + 0.02)
    payload = {"pressure": round(pressure, 2), "level": round(level, 2),
               "running": state["running"]}
    client.publish(f"{P}/telemetry", json.dumps(payload), qos=0, retain=True)
    time.sleep(1)

La telemetría se publica como retenida, así que el gemelo muestra los últimos valores en cuanto se conecta en lugar de esperar un segundo. Comprueba el flujo con mosquitto_sub -h broker.connectmq.com -p 1883 -i chk-7f3a -v -t "connectmq/demo/bjs-7f3a/#".

Paso 2: construye el gemelo digital con Babylon.js

Guarda esto como index.html y sírvelo con cualquier servidor estático (npx serve .). Carga Babylon.js desde la CDN oficial y MQTT.js como bundle para navegador. En producción, fija ambos a versiones exactas que hayas probado.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Babylon.js Digital Twin</title>
  <style>
    html, body { margin: 0; height: 100%; overflow: hidden; background: #111827; }
    #c { width: 100%; height: 100%; touch-action: none; }
    #info { position: absolute; top: 10px; left: 10px; color: #fff; font: 14px system-ui;
            background: rgba(0,0,0,.6); padding: 8px 12px; border-radius: 6px; }
  </style>
  <script src="https://cdn.babylonjs.com/babylon.js"></script>
  <script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
</head>
<body>
  <canvas id="c"></canvas>
  <div id="info">Connecting...</div>
  <script>
    const PREFIX = "connectmq/demo/bjs-7f3a/skid1";
    const ALARM_BAR = 6.0;
    const info = document.getElementById("info");

    const canvas = document.getElementById("c");
    const engine = new BABYLON.Engine(canvas, true);
    const scene = new BABYLON.Scene(engine);
    scene.clearColor = new BABYLON.Color4(0.07, 0.09, 0.15, 1);

    const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI / 3, Math.PI / 3, 14, new BABYLON.Vector3(0, 1, 0), scene);
    camera.attachControl(canvas, true);
    new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0.3, 1, 0.2), scene);
    BABYLON.MeshBuilder.CreateGround("ground", { width: 16, height: 10 }, scene);

    const mat = (name, hex) => {
      const m = new BABYLON.StandardMaterial(name, scene);
      m.diffuseColor = BABYLON.Color3.FromHexString(hex);
      return m;
    };

    // Bomba: cuerpo + rotor
    const pump = BABYLON.MeshBuilder.CreateCylinder("pump", { diameter: 1.6, height: 1.2 }, scene);
    pump.rotation.z = Math.PI / 2;
    pump.position.set(-3, 0.8, 0);
    pump.material = mat("pumpMat", "#3b82f6");
    const rotor = BABYLON.MeshBuilder.CreateBox("rotor", { width: 0.1, height: 1.4, depth: 0.2 }, scene);
    rotor.position.set(-2.3, 0.8, 0);
    rotor.material = mat("rotorMat", "#e5e7eb");

    // Tanque con líquido que escala en Y (origen desplazado a la base)
    const shell = BABYLON.MeshBuilder.CreateCylinder("shell", { diameter: 2.2, height: 3 }, scene);
    shell.position.set(1, 1.5, 0);
    shell.material = mat("shellMat", "#9ca3af");
    shell.material.alpha = 0.25;
    const liquid = BABYLON.MeshBuilder.CreateCylinder("liquid", { diameter: 2, height: 1 }, scene);
    liquid.setPivotPoint(new BABYLON.Vector3(0, -0.5, 0));
    liquid.position.set(1, 0.5, 0);
    liquid.material = mat("liquidMat", "#0ea5e9");

    // Manómetro: esfera + aguja que pivota en su base
    const dial = BABYLON.MeshBuilder.CreateDisc("dial", { radius: 0.9, tessellation: 48 }, scene);
    dial.position.set(4.5, 2, -0.01);
    dial.material = mat("dialMat", "#f9fafb");
    const needle = BABYLON.MeshBuilder.CreateBox("needle", { width: 0.06, height: 0.8, depth: 0.02 }, scene);
    needle.setPivotPoint(new BABYLON.Vector3(0, -0.4, 0));
    needle.position.set(4.5, 2.4, -0.05);
    needle.material = mat("needleMat", "#dc2626");

    const highlight = new BABYLON.HighlightLayer("hl", scene);

    // ---------- Estado ----------
    const target = { pressure: 0, level: 0, running: false, online: false };
    const cur = { pressure: 0, level: 0, rotorSpeed: 0 };
    let alarmOn = false;

    // ---------- MQTT ----------
    const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
      clientId: "bjs-twin-" + Math.random().toString(16).slice(2, 10),
      protocolVersion: 5,
      reconnectPeriod: 2000,
    });
    const dec = new TextDecoder();
    client.on("connect", () => client.subscribe(`${PREFIX}/#`, { qos: 1 }));
    client.on("message", (topic, payload) => {
      const text = dec.decode(payload);
      if (topic === `${PREFIX}/status`) { target.online = text === "online"; return; }
      if (topic !== `${PREFIX}/telemetry`) return;
      try {
        const d = JSON.parse(text);
        if (typeof d.pressure === "number") target.pressure = d.pressure;
        if (typeof d.level === "number") target.level = Math.min(Math.max(d.level, 0), 1);
        if (typeof d.running === "boolean") target.running = d.running;
      } catch { /* ignorar payloads mal formados */ }
    });

    // ---------- Clic en la bomba para alternar su estado ----------
    pump.actionManager = new BABYLON.ActionManager(scene);
    pump.actionManager.registerAction(new BABYLON.ExecuteCodeAction(
      BABYLON.ActionManager.OnPickTrigger, () => {
        const cmd = target.running ? "STOP" : "START";
        if (confirm(`Send ${cmd} to pump?`)) client.publish(`${PREFIX}/cmd`, cmd, { qos: 1 });
      }));
    pump.actionManager.registerAction(new BABYLON.SetValueAction(
      BABYLON.ActionManager.OnPointerOverTrigger, pump, "scaling", new BABYLON.Vector3(1.05, 1.05, 1.05)));
    pump.actionManager.registerAction(new BABYLON.SetValueAction(
      BABYLON.ActionManager.OnPointerOutTrigger, pump, "scaling", new BABYLON.Vector3(1, 1, 1)));

    // ---------- Actualización por fotograma ----------
    scene.onBeforeRenderObservable.add(() => {
      const dt = engine.getDeltaTime() / 1000;
      const k = 1 - Math.exp(-dt * 4);
      cur.pressure += (target.pressure - cur.pressure) * k;
      cur.level += (target.level - cur.level) * k;
      cur.rotorSpeed += ((target.running ? 12 : 0) - cur.rotorSpeed) * k;

      liquid.scaling.y = Math.max(cur.level * 2.9, 0.01);
      rotor.rotation.x += cur.rotorSpeed * dt;
      // 0 bar = -120 grados, 10 bar = +120 grados
      needle.rotation.z = -BABYLON.Tools.ToRadians(-120 + (Math.min(cur.pressure, 10) / 10) * 240);

      pump.material.diffuseColor = BABYLON.Color3.FromHexString(
        !target.online ? "#6b7280" : target.running ? "#22c55e" : "#3b82f6");

      const shouldAlarm = target.online && target.pressure > ALARM_BAR;
      if (shouldAlarm !== alarmOn) {
        alarmOn = shouldAlarm;
        if (alarmOn) highlight.addMesh(pump, BABYLON.Color3.Red());
        else highlight.removeMesh(pump);
      }

      info.textContent = `${client.connected ? "Live" : "Disconnected"} | ` +
        `${cur.pressure.toFixed(2)} bar | level ${(cur.level * 100).toFixed(0)} % | ` +
        `pump ${target.running ? "running" : "stopped"}${alarmOn ? " | HIGH PRESSURE" : ""}`;
    });

    engine.runRenderLoop(() => scene.render());
    addEventListener("resize", () => engine.resize());
  </script>
</body>
</html>

Abre la página con el simulador en marcha. El tanque se vacía lentamente mientras la bomba funciona, la aguja oscila con la presión, la bomba se ilumina en rojo cada vez que la presión supera los 6 bar y, al hacer clic en la bomba, se envía un comando confirmado. Detén el simulador con Ctrl+C y, en cuanto el broker publique el last will, la bomba se vuelve gris.

Cómo funcionan las vinculaciones en Babylon.js

Puntos de pivote en lugar de editar la geometría. setPivotPoint desplaza el origen de escalado y rotación a la base del líquido y a la base de la aguja, de modo que el escalado llena desde el suelo y la aguja gira alrededor de su eje. Es el equivalente en Babylon.js a fijar el origen en Blender.

onBeforeRenderObservable como único punto de actualización. El manejador MQTT solo escribe en target. Todo lo visual ocurre una vez por fotograma, suavizado con un factor independiente de la tasa de fotogramas. Así la animación sigue siendo fluida aunque los mensajes lleguen de forma irregular.

Cambios de estado, no trabajo en cada fotograma. La capa de resaltado solo se modifica cuando cambia el estado de alarma. Añadir y quitar mallas en cada fotograma desperdiciaría trabajo y puede provocar parpadeos.

Comandos con confirmación. Es fácil hacer clic por accidente en un objeto 3D, sobre todo mientras giras la cámara. El diálogo confirm() es la protección mínima; los despliegues reales suelen exigir además una comprobación de rol. Los comandos usan QoS 1 para sobrevivir a un breve corte de red; los niveles de QoS de MQTT explicados muestra por qué.

El dispositivo confirma el resultado. El color de la bomba refleja running de la telemetría, no el botón que pulsaste. Si el equipo rechaza el comando o no lo recibe, el gemelo muestra la verdad.

Consejos de rendimiento para gemelos más grandes

Un skid con tres mallas funciona sin esfuerzo, pero una línea completa con cientos de piezas requiere algo de cuidado. Congela las mallas que nunca se mueven con mesh.freezeWorldMatrix() para que Babylon.js omita la actualización de sus matrices. Comparte materiales entre las piezas estáticas y clona solo aquellos cuyo color está vinculado a datos. Usa instancias (mesh.createInstance) para elementos repetidos como rodillos o palés. Mantén una sola conexión MQTT por página con una suscripción con comodín y reparte los mensajes localmente, en lugar de abrir una conexión por activo. Por último, limita la frecuencia de los textos superpuestos: reescribir elementos del DOM a 60 fotogramas por segundo suele costar más que el propio renderizado 3D, así que actualízalos unas pocas veces por segundo.

Lista de comprobación para resolver problemas

  1. Se queda en "Connecting...": confirma que la URL es wss://broker.connectmq.com:8084/mqtt, incluida la ruta /mqtt, y revisa la consola del navegador en busca de errores de WebSocket. Los proxies corporativos a veces bloquean la actualización a WebSocket.
  2. BABYLON is not defined: la etiqueta script de Babylon.js no se cargó; revisa la pestaña de red y cualquier política de seguridad de contenidos.
  3. Los valores nunca aparecen: el prefijo de topic de la página es distinto del del simulador. Compáralos carácter a carácter.
  4. La conexión se cae una y otra vez con dos pestañas abiertas: cada pestaña debe usar un client ID único, algo que resuelve el sufijo aleatorio.
  5. Usa el inspector: llama a scene.debugLayer.show() en la consola para inspeccionar mallas, materiales y pivotes en vivo.

Para conocer los puertos y rutas de WebSocket, consulta MQTT sobre WebSocket.

De primitivas a un modelo real de planta

Sustituye las primitivas por un modelo glTF añadiendo el script de loaders de Babylon.js y llamando a BABYLON.SceneLoader.ImportMeshAsync("", "/models/", "skid.glb", scene); después localiza las piezas con scene.getMeshByName("Pump_Body"). Las convenciones de nombres y los patrones de mapeo son los mismos en cualquier motor; vincular datos IoT a un modelo 3D los explica, y la guía para crear un gemelo digital 3D web muestra una estructura basada en configuración que escala a una fábrica entera.

Producción: tu propio broker

El servidor público gratuito es anónimo y visible para cualquiera, lo cual está bien para este tutorial y mal para una planta: nunca lo uses con datos de producción. Un broker MQTT gestionado te da credenciales, TLS y aislamiento. Con ConnectMQ creas un broker en el portal y recibes un nombre de usuario, una contraseña y un client ID generados; añádelos a las opciones de mqtt.connect como username, password y clientId (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID). El WebSocket seguro en el puerto 8084 usa un certificado de Let's Encrypt, así que los navegadores confían en él sin configuración. Sirve la página del gemelo detrás de tu propio inicio de sesión, ya que todo lo que se entrega al navegador puede ser leído por sus usuarios.

Empieza con ConnectMQ

La demo web de gemelo digital 3D de ConnectMQ muestra una escena de fábrica completa transmitiendo datos por WebSocket seguro, y el mismo broker está disponible para tu propio gemelo digital con Babylon.js. Crea un broker gratuito en app.connectmq.com: el plan Serverless no requiere tarjeta de crédito, admite MQTT 5 y hasta 25 conexiones, e incluye un explorador de topics en vivo para comprobar tu telemetría antes de vincularla. Mientras tanto, el servidor MQTT público gratuito está listo para experimentar.

Preguntas frecuentes

¿Es mejor Babylon.js o Three.js para gemelos digitales?

Ambos funcionan bien. Babylon.js incluye más funciones integradas, como acciones de picking, capas de resaltado y un inspector, mientras que Three.js es más pequeño y modular; elige aquel con el que tu equipo se sienta más cómodo.

¿Cómo se conecta una página de Babylon.js a MQTT?

Mediante MQTT.js ejecutándose en la misma página a través de WebSocket, porque los navegadores no pueden abrir conexiones TCP directas. El broker debe ofrecer un endpoint de MQTT sobre WebSocket, idealmente WebSocket seguro.

¿Puedo hacer clic en objetos del gemelo para controlar equipos?

Sí. Asocia un ActionManager a una malla y publica un comando en OnPickTrigger. Añade una confirmación y deja que el dispositivo informe de vuelta su estado real.

¿Cómo cargo mi propio modelo CAD en Babylon.js?

Conviértelo a glTF (.glb), reduce su número de polígonos, da nombres únicos a las piezas vinculadas y cárgalo con SceneLoader.ImportMeshAsync.

Artículos relacionados

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.