Gemelo digital

Gemelo digital con Three.js: visualización 3D en vivo con MQTT

Construye en el navegador un gemelo digital con Three.js de un depósito y un motor que reaccionan a la telemetría MQTT en vivo por WebSocket seguro, con un simulador en Node.js, interpolación suave y consejos para producción.

Gemelo digital con Three.js de un depósito y un motor que cambian de color con datos de sensores MQTT en vivo en un navegador
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 Three.js convierte un flujo de mensajes de sensores en una escena 3D que puedes orbitar, ampliar y entender de un vistazo. En este tutorial construirás uno en un único archivo HTML: un depósito cuyo nivel de líquido sube y baja, y un motor que cambia de color con la temperatura y gira a la velocidad que informa, todo ello alimentado por MQTT sobre WebSocket seguro desde broker.connectmq.com. También escribirás un pequeño simulador para poder probar el gemelo antes de que exista ningún hardware real.

Qué necesita un gemelo digital con Three.js

Un gemelo digital web tiene tres piezas móviles, y mantenerlas separadas es lo que lo hace mantenible:

  1. Una fuente de datos. Los dispositivos publican telemetría en topics MQTT. El navegador se suscribe con MQTT.js sobre WebSocket, porque los navegadores no pueden abrir sockets TCP en bruto.
  2. Un almacén de estado. Los mensajes entrantes actualizan un objeto JavaScript simple con los valores objetivo. Nada en el manejador de mensajes toca directamente la escena 3D.
  3. Un bucle de renderizado. En cada fotograma de animación, la escena acerca suavemente sus valores actuales a los objetivos y vuelve a dibujar.

Esta separación importa más de lo que parece. Los mensajes pueden llegar en ráfagas, desacompasados de los 60 fotogramas por segundo a los que renderiza el navegador. Si modificas las mallas dentro del callback de MQTT, obtienes movimientos a saltos y condiciones de carrera difíciles de encontrar. Con un almacén de estado en medio, el renderizador trabaja siempre a partir de una instantánea coherente y puedes interpolar con suavidad.

Para ver dónde encaja esto en el panorama general, nuestra guía de tecnología de gemelo digital explica la diferencia entre un gemelo visual como este y los gemelos que además simulan o predicen.

Diseño de topics para el gemelo

Usa un topic por activo y un payload JSON por mensaje. Elige un sufijo aleatorio para el prefijo y así no chocar con otras personas que estén probando en el broker público:

Topic Payload de ejemplo Qué controla en la escena
connectmq/demo/twin-7f3a/tank1/telemetry {"level":0.62,"ts":1760600000000} Altura del líquido (de 0 a 1)
connectmq/demo/twin-7f3a/motor1/telemetry {"temperature":58.4,"rpm":1320,"ts":...} Color del cuerpo y rotación del ventilador
connectmq/demo/twin-7f3a/motor1/status online / offline (retenido) Pasar a gris cuando está desconectado

Un topic por activo mantiene sencilla la suscripción del navegador (un único comodín # bajo tu prefijo) y escala limpiamente a decenas de activos. Los valores normalizados, como un nivel entre 0 y 1, también mantienen el código de la escena libre de conversiones de unidades. Si estás diseñando topics para un gemelo más grande, MQTT para gemelos digitales trata con más profundidad las convenciones de jerarquía y de payload.

Paso 1: simula el equipo

No necesitas hardware para empezar. Este script de Node.js publica una vez por segundo datos de aspecto realista para el depósito y el motor. Guárdalo como simulator.mjs, ejecuta npm install mqtt y después node simulator.mjs.

import mqtt from "mqtt";

const PREFIX = "connectmq/demo/twin-7f3a";
const client = mqtt.connect("mqtts://broker.connectmq.com:8883", {
  clientId: "twin-sim-7f3a",
  protocolVersion: 5,
  will: { topic: `${PREFIX}/motor1/status`, payload: "offline", qos: 1, retain: true },
});

let t = 0;

client.on("connect", () => {
  console.log("Simulator connected");
  client.publish(`${PREFIX}/motor1/status`, "online", { qos: 1, retain: true });

  setInterval(() => {
    t += 1;
    const level = 0.5 + 0.4 * Math.sin(t / 20);
    const temperature = 50 + 30 * Math.sin(t / 15);
    const rpm = temperature > 75 ? 0 : Math.round(1200 + 300 * Math.sin(t / 7));

    client.publish(`${PREFIX}/tank1/telemetry`,
      JSON.stringify({ level: Number(level.toFixed(3)), ts: Date.now() }));
    client.publish(`${PREFIX}/motor1/telemetry`,
      JSON.stringify({ temperature: Number(temperature.toFixed(1)), rpm, ts: Date.now() }));
  }, 1000);
});

client.on("error", (err) => console.error("MQTT error:", err.message));

El motor "dispara" la protección y se detiene cada vez que la temperatura simulada supera los 75 grados, lo que le da al gemelo algo interesante que mostrar. Dos mensajes por segundo están muy por debajo del límite del broker público de 10 mensajes por segundo por cliente. Confirma que los datos fluyen antes de tocar ningún código 3D:

mosquitto_sub -h broker.connectmq.com -p 1883 -i check-7f3a -v -t "connectmq/demo/twin-7f3a/#"

Paso 2: construye la escena del gemelo digital con Three.js

Guarda lo siguiente como index.html. Usa un import map para cargar Three.js y MQTT.js como módulos ES desde un CDN, así que no hay paso de compilación. Sírvelo con cualquier servidor estático, por ejemplo npx serve ., y abre la URL que se imprime. Abrir el archivo directamente desde el disco también funciona en la mayoría de los navegadores, pero un servidor local evita las restricciones de carga de módulos.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Three.js Digital Twin</title>
  <style>
    body { margin: 0; overflow: hidden; font-family: system-ui, sans-serif; background: #1d2330; }
    #hud { position: absolute; top: 12px; left: 12px; color: #fff; background: rgba(0,0,0,.55);
           padding: 8px 12px; border-radius: 6px; font-size: 14px; line-height: 1.5; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
      "mqtt": "https://unpkg.com/mqtt@5.10.1/dist/mqtt.esm.js"
    }
  }
  </script>
</head>
<body>
  <div id="hud">Connecting...</div>
  <script type="module">
    import * as THREE from "three";
    import { OrbitControls } from "three/addons/controls/OrbitControls.js";
    import mqtt from "mqtt";

    const PREFIX = "connectmq/demo/twin-7f3a";
    const hud = document.getElementById("hud");

    // ---------- Escena ----------
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x1d2330);
    const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
    camera.position.set(6, 5, 8);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.setSize(innerWidth, innerHeight);
    document.body.appendChild(renderer.domElement);
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.target.set(0, 1, 0);
    controls.enableDamping = true;

    scene.add(new THREE.HemisphereLight(0xffffff, 0x444466, 1.2));
    const sun = new THREE.DirectionalLight(0xffffff, 1.5);
    sun.position.set(5, 10, 5);
    scene.add(sun, new THREE.GridHelper(20, 20, 0x556070, 0x333a48));

    // Depósito: carcasa transparente + columna de líquido (geometría de altura 1, escalada en Y)
    const TANK_H = 3;
    const shell = new THREE.Mesh(
      new THREE.CylinderGeometry(1, 1, TANK_H, 40, 1, true),
      new THREE.MeshStandardMaterial({ color: 0x99aabb, transparent: true, opacity: 0.25, side: THREE.DoubleSide })
    );
    shell.position.set(-2, TANK_H / 2, 0);
    const liquid = new THREE.Mesh(
      new THREE.CylinderGeometry(0.95, 0.95, 1, 40),
      new THREE.MeshStandardMaterial({ color: 0x2f7fd8 })
    );
    liquid.position.x = -2;
    scene.add(shell, liquid);

    // Motor: cuerpo + ventilador que gira según las rpm
    const motorMat = new THREE.MeshStandardMaterial({ color: 0x44aa66 });
    const motor = new THREE.Mesh(new THREE.BoxGeometry(1.4, 1, 1), motorMat);
    motor.position.set(2, 0.5, 0);
    const fan = new THREE.Mesh(
      new THREE.BoxGeometry(0.08, 0.9, 0.15),
      new THREE.MeshStandardMaterial({ color: 0xdddddd })
    );
    fan.position.set(2.75, 0.5, 0);
    scene.add(motor, fan);

    // ---------- Almacén de estado ----------
    const target = { level: 0, temperature: 20, rpm: 0, online: false };
    const current = { level: 0, temperature: 20, rpm: 0 };
    let lastMessageAt = 0;

    // ---------- MQTT ----------
    const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
      clientId: "twin-viewer-" + Math.random().toString(16).slice(2, 10),
      protocolVersion: 5,
      reconnectPeriod: 2000,
    });
    const decoder = new TextDecoder();

    client.on("connect", () => {
      hud.textContent = "Connected";
      client.subscribe(`${PREFIX}/#`, { qos: 0 });
    });
    client.on("reconnect", () => (hud.textContent = "Reconnecting..."));
    client.on("message", (topic, payload) => {
      const text = decoder.decode(payload);
      lastMessageAt = performance.now();
      if (topic.endsWith("/motor1/status")) { target.online = text === "online"; return; }
      let data;
      try { data = JSON.parse(text); } catch { return; }
      if (topic.endsWith("/tank1/telemetry") && typeof data.level === "number") {
        target.level = THREE.MathUtils.clamp(data.level, 0, 1);
      } else if (topic.endsWith("/motor1/telemetry")) {
        if (typeof data.temperature === "number") target.temperature = data.temperature;
        if (typeof data.rpm === "number") target.rpm = data.rpm;
      }
    });

    // ---------- Bucle de renderizado ----------
    const clock = new THREE.Clock();
    const cold = new THREE.Color(0x2e86de), hot = new THREE.Color(0xe74c3c), grey = new THREE.Color(0x666666);

    function animate() {
      requestAnimationFrame(animate);
      const dt = clock.getDelta();
      const k = 1 - Math.exp(-dt * 4); // suavizado independiente de la tasa de fotogramas
      current.level += (target.level - current.level) * k;
      current.temperature += (target.temperature - current.temperature) * k;
      current.rpm += (target.rpm - current.rpm) * k;

      const h = Math.max(current.level * (TANK_H - 0.1), 0.001);
      liquid.scale.y = h;
      liquid.position.y = h / 2 + 0.05;

      const t01 = THREE.MathUtils.clamp((current.temperature - 20) / 70, 0, 1);
      if (target.online) motorMat.color.copy(cold).lerp(hot, t01);
      else motorMat.color.copy(grey);

      fan.rotation.x += (current.rpm / 60) * Math.PI * 2 * dt * 0.05; // ralentizado para que se vea

      const stale = performance.now() - lastMessageAt > 5000;
      hud.innerHTML = `Level ${(current.level * 100).toFixed(0)} %<br>` +
        `Motor ${current.temperature.toFixed(1)} C, ${Math.round(current.rpm)} rpm` +
        (stale ? "<br>No data for 5 s" : "");

      controls.update();
      renderer.render(scene, camera);
    }
    animate();

    addEventListener("resize", () => {
      camera.aspect = innerWidth / innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(innerWidth, innerHeight);
    });
  </script>
</body>
</html>

Con el simulador en marcha, la columna de líquido sube y baja, el motor pasa del azul al rojo a medida que se calienta, el ventilador se detiene cuando salta la protección del motor y el motor se vuelve gris si detienes el simulador y se dispara su last will.

Cómo funcionan las vinculaciones en vivo

Merece la pena copiar algunos detalles de ese archivo en cualquier gemelo digital con Three.js que construyas.

Escala una geometría de altura unitaria. El cilindro de líquido se crea con altura 1, así que scale.y equivale a su altura en unidades de la escena. Mover position.y a la mitad de la altura mantiene la base anclada al fondo del depósito. Es más barato y sencillo que reconstruir la geometría con cada mensaje.

Modifica los materiales, no los sustituyas. Llamar a material.color.copy(...) actualiza el material existente. Crear un material nuevo por mensaje agota recursos de la GPU y provoca recompilaciones de shaders.

Acércate suavemente a los objetivos. La expresión 1 - Math.exp(-dt * 4) da un suavizado independiente de la tasa de fotogramas: el movimiento se ve igual a 30 que a 144 fotogramas por segundo. Con un mensaje por segundo, los valores en bruto harían que la escena saltara una vez por segundo.

Muestra la obsolescencia. Un gemelo que se congela en silencio es peor que no tener gemelo, porque la gente confía en él. El HUD avisa cuando no ha llegado ningún mensaje en cinco segundos, y el topic status retenido junto con el last will del simulador te indica si el propio dispositivo ha desaparecido. Los retained messages también hacen que una página recién abierta muestre al instante el último estado conocido; consulta retained messages para gemelos digitales para saber cuándo retener y cuándo no.

Errores habituales

Síntoma Causa probable Solución
La página carga, pero el HUD se queda en "Connecting..." URL incorrecta o falta la ruta /mqtt Usa wss://broker.connectmq.com:8084/mqtt
Se conecta y se desconecta cada pocos segundos Client ID duplicado entre pestañas Genera un client ID aleatorio en cada carga de página
Pantalla negra, errores sobre módulos Archivo abierto de un modo que bloquea las importaciones de módulos Sírvelo con npx serve . u otro servidor estático
Los valores se actualizan, pero el movimiento va a saltos Mallas actualizadas dentro del callback de MQTT Escribe en el almacén de estado y anima en el bucle de renderizado
Error de contenido mixto en un sitio HTTPS Uso de ws:// desde una página https:// Usa siempre wss:// en el puerto 8084

Si los mensajes no llegan en absoluto, revisa la conexión WebSocket en la pestaña Network de las herramientas de desarrollo del navegador, y lee MQTT sobre WebSocket para una explicación completa de puertos, rutas y proxies.

De las primitivas a los modelos reales

Las cajas y los cilindros son perfectos para demostrar la ruta de los datos. El siguiente paso suele ser un modelo glTF exportado desde Blender o una herramienta CAD, en el que buscas las mallas por nombre y les vinculas los topics. Eso es exactamente lo que trata vincular datos IoT a un modelo 3D, incluidos materiales, morph targets y clips de animación. Si quieres la estructura completa de principio a fin de un gemelo web de producción con vinculaciones guiadas por configuración, sigue la guía para construir un gemelo digital 3D basado en web. ¿Prefieres otro motor? El mismo patrón MQTT funciona en Babylon.js.

Paso a producción

El broker público es estupendo para este tutorial, pero es anónimo y cualquiera puede leerlo, así que nunca transmitas por él datos reales de planta. En producción quieres tu propio broker con credenciales y TLS. ConnectMQ ofrece un broker MQTT gestionado con WebSocket seguro en el puerto 8084 y un certificado de confianza de Let's Encrypt, así que la conexión desde el navegador funciona sin configurar ningún certificado. Añade las credenciales a las opciones de MQTT.js:

const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
  clientId: "YOUR_CLIENT_ID",
  username: "YOUR_USERNAME",
  password: "YOUR_PASSWORD",
  protocolVersion: 5,
});

Todo lo que se envía a un navegador es visible para sus usuarios, así que dale a la página del visor sus propias credenciales, separadas de las de los dispositivos para poder rotarlas de forma independiente, y sirve la página solo a usuarios autenticados. Muchos equipos ponen un inicio de sesión ligero delante de la página e inyectan las credenciales MQTT tras el acceso.

Empieza con ConnectMQ

ConnectMQ incluye su propia demo web de gemelo digital 3D, una fábrica con una máquina CNC, un brazo robótico, un horno, un depósito, una cinta transportadora y una bomba que se actualizan en vivo por WebSocket seguro, construida exactamente con el patrón de este artículo. Para ejecutar tu propio gemelo digital con Three.js en un broker privado, crea un broker gratuito en app.connectmq.com. El plan Serverless gratuito no requiere tarjeta de crédito y admite hasta 25 conexiones, suficientes para unos cuantos dispositivos y visores, y el explorador de topics en tiempo real del portal te ayuda a comprobar cada vinculación. Antes de dar ese paso, puedes seguir experimentando con datos de demostración en el servidor MQTT público gratuito.

Preguntas frecuentes

¿Puede Three.js conectarse directamente a MQTT?

Three.js solo renderiza; la conexión la gestiona MQTT.js en la misma página. MQTT.js usa WebSocket en el navegador, así que el broker debe exponer un endpoint de MQTT sobre WebSocket, como el puerto 8084 con la ruta /mqtt.

¿Cuántos objetos puede actualizar en tiempo real un gemelo digital en el navegador?

Actualizar transformaciones y colores de materiales es barato, así que cientos de objetos vinculados son realistas en hardware corriente. El cuello de botella habitual es la complejidad de la geometría y las draw calls, no el manejo de los mensajes MQTT.

¿El gemelo debe suscribirse con QoS 0 o QoS 1?

QoS 0 suele ser lo adecuado para la telemetría visual, porque un valor más reciente sustituye a uno perdido en cuestión de segundos. Usa QoS 1 para comandos y alarmas que no deben perderse.

¿Necesito un servidor backend para un gemelo digital con Three.js?

No para la visualización en vivo: el navegador habla directamente con el broker. Solo necesitas un backend para cosas como consultas históricas, autenticación de usuarios o la emisión de credenciales por usuario.

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.