Un gemelo digital 3D web permite que cualquier persona con un navegador vea el estado en vivo de una máquina, una línea o una planta completa sin instalar software. Esta guía explica cómo construir uno correctamente: preparar un modelo glTF, configurar un proyecto Vite, vincular topics MQTT a partes del modelo mediante un archivo de configuración, añadir detalles al hacer clic y desplegar el resultado como sitio estático. Los datos llegan desde el servidor MQTT público gratuito broker.connectmq.com a través de WebSocket seguro, el mismo enfoque que usa la propia demo de fábrica 3D de ConnectMQ.
Arquitectura de un gemelo digital 3D web
Antes de escribir código conviene ver el sistema completo. Un gemelo en el navegador es sobre todo una aplicación front-end; el broker hace el trabajo pesado de mover los datos.
| Capa | Responsabilidad | Tecnología en esta guía |
|---|---|---|
| Dispositivos y gateways | Publicar telemetría y estado | Cualquier cliente MQTT (gateway de PLC, ESP32, Python) |
| Broker | Enrutar mensajes, conservar el estado retenido | Broker MQTT gestionado, WSS en el puerto 8084 |
| Modelo | Geometría, partes con nombre, materiales | glTF binario (.glb) |
| Configuración de vinculaciones | Qué campo de qué topic controla qué parte y cómo | Un módulo de configuración en JavaScript |
| Runtime | Cargar el modelo, suscribirse, animar, interactuar | Three.js y MQTT.js en una app Vite |
La idea clave es la configuración de vinculaciones. En lugar de escribir código para cada máquina, escribes unos pocos comportamientos genéricos (brillo por calor, nivel de llenado, rotación, color de estado) y una lista que conecta topics con partes del modelo. Añadir una nueva bomba significa entonces añadir una línea de configuración y una malla con nombre, sin tocar el runtime. Si el concepto es nuevo para ti, la introducción a la tecnología de gemelo digital explica cómo se relacionan los gemelos visuales con los gemelos de simulación y de analítica.
Paso 1: planifica los activos y los topics
Una escena de fábrica típica, como la de la demo mencionada, tiene una máquina CNC, un brazo robótico, un horno, un tanque, una cinta transportadora y una bomba. Usaremos cuatro de ellos como ejemplo. Escribe primero el plan; se convertirá en tu archivo de configuración.
| Activo | Topic (bajo tu prefijo) | Campo | Comportamiento visual |
|---|---|---|---|
| Horno | furnace1/telemetry |
temperature (20 a 900) |
Brillo emisivo de oscuro a naranja |
| Tanque | tank1/telemetry |
level (0 a 1) |
La malla del líquido escala en Y |
| Bomba | pump1/telemetry |
rpm (0 a 3000) |
El impulsor gira |
| Máquina CNC | cnc1/state |
state |
Color de baliza: en marcha, en espera, alarma |
Para la demo usamos el prefijo connectmq/demo/factory-7f3a; sustituye el sufijo por tu propia cadena aleatoria. En producción usarías un espacio de nombres jerárquico como acme/plant1/area2/line1/furnace1, y nuestro artículo sobre MQTT para gemelos digitales explica cómo diseñarlo.
Paso 2: prepara el modelo 3D
Venga de la herramienta que venga tu modelo (Blender, una exportación CAD, un asset comprado), prepáralo para el gemelo antes de exportarlo:
- Da a cada parte vinculada un nombre único y significativo, por ejemplo
Furnace,Tank_Liquid,Pump_Impeller,CNC_Beacon. Los nombres sobreviven a la exportación glTF y son la forma en que el runtime encuentra las partes. - Separa las partes móviles en sus propios objetos con el pivote en el centro de rotación. Un impulsor fusionado con la carcasa de la bomba no puede girar por sí solo.
- Da al líquido una altura unitaria con el origen en la base, para que al escalar en Y se llene desde el suelo hacia arriba.
- Reduce polígonos en todo lo que no sea visualmente importante. Las exportaciones CAD suelen tener millones de triángulos; un gemelo en el navegador normalmente debe quedarse muy por debajo.
- Exporta como glTF binario (
.glb) con "Apply modifiers" activado. Opcionalmente, comprime con Draco o Meshopt para una carga más rápida.
El código siguiente incluye un plan B que construye cajas simples con los mismos nombres si no se encuentra ningún modelo, para que puedas seguir la guía sin tener uno.
Paso 3: crea la estructura del proyecto
npm create vite@latest web-twin -- --template vanilla
cd web-twin
npm install three mqtt
mkdir -p public/models # pon factory.glb aquí
El runtime espera el modelo con await en el nivel superior de un módulo, así que indica a Vite que apunte a navegadores modernos creando vite.config.js:
export default { build: { target: "es2022" } };
Crea un archivo .env para los ajustes que cambian entre entornos. Vite expone al navegador las variables con el prefijo VITE_:
VITE_MQTT_URL=wss://broker.connectmq.com:8084/mqtt
VITE_TOPIC_PREFIX=connectmq/demo/factory-7f3a
Paso 4: escribe la configuración de vinculaciones
Crea src/bindings.js. Cada entrada indica qué topic y campo leer, qué parte del modelo cambiar y qué comportamiento aplicar.
export const PREFIX = import.meta.env.VITE_TOPIC_PREFIX;
export const bindings = [
{ topic: "furnace1/telemetry", field: "temperature", node: "Furnace", kind: "heat", min: 20, max: 900 },
{ topic: "tank1/telemetry", field: "level", node: "Tank_Liquid", kind: "fill", min: 0, max: 1 },
{ topic: "pump1/telemetry", field: "rpm", node: "Pump_Impeller", kind: "spin", axis: "z" },
{ topic: "cnc1/state", field: "state", node: "CNC_Beacon", kind: "status" },
];
export const statusColors = {
RUNNING: 0x2ecc71,
IDLE: 0xf1c40f,
ALARM: 0xe74c3c,
OFFLINE: 0x7f8c8d,
};
Paso 5: el runtime del gemelo
Sustituye src/main.js por el runtime siguiente. Carga el modelo (o construye marcadores de posición), clona los materiales para que las partes cambien de color de forma independiente, se conecta a MQTT, enruta los mensajes a través de las vinculaciones y lo anima todo en un único bucle de renderizado.
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import mqtt from "mqtt";
import { PREFIX, bindings, statusColors } from "./bindings.js";
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x182030);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 200);
camera.position.set(10, 8, 12);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.style.margin = "0";
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.3));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(8, 15, 6);
scene.add(sun, new THREE.GridHelper(30, 30, 0x4a5568, 0x2d3748));
const panel = Object.assign(document.createElement("pre"), {
style: "position:absolute;top:10px;left:10px;margin:0;padding:10px;color:#fff;" +
"background:rgba(0,0,0,.6);border-radius:6px;font:13px monospace;max-width:320px",
textContent: "Loading model...",
});
document.body.appendChild(panel);
// ----- Carga del modelo con marcadores de posición como alternativa -----
function buildPlaceholders() {
const group = new THREE.Group();
const add = (name, geo, color, pos) => {
const m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color }));
m.name = name; m.position.set(...pos); group.add(m); return m;
};
add("Furnace", new THREE.BoxGeometry(2, 2, 2), 0x553322, [-4, 1, 0]);
const liquid = add("Tank_Liquid", new THREE.CylinderGeometry(0.9, 0.9, 1, 32), 0x2f7fd8, [0, 0, 0]);
liquid.geometry.translate(0, 0.5, 0); // origen en la base
add("Pump_Impeller", new THREE.BoxGeometry(0.2, 1.4, 0.2), 0xcccccc, [3, 1, 0]);
add("CNC_Beacon", new THREE.SphereGeometry(0.3, 24, 16), 0x7f8c8d, [6, 2.4, 0]);
add("CNC_Body", new THREE.BoxGeometry(2, 2, 1.5), 0x8899aa, [6, 1, 0]);
return group;
}
async function loadModel() {
try {
const gltf = await new GLTFLoader().loadAsync("/models/factory.glb");
return gltf.scene;
} catch {
console.warn("factory.glb not found, using placeholders");
return buildPlaceholders();
}
}
const root = await loadModel();
scene.add(root);
// ----- Resolver vinculaciones a objetos y darles materiales propios -----
const runtime = bindings.map((b) => {
const obj = root.getObjectByName(b.node);
if (!obj) console.warn(`Binding target "${b.node}" not found in model`);
obj?.traverse((o) => { if (o.isMesh) o.material = o.material.clone(); });
return { ...b, obj, target: b.min ?? 0, current: b.min ?? 0, last: null, at: 0 };
});
const byTopic = new Map();
for (const r of runtime) {
const full = `${PREFIX}/${r.topic}`;
if (!byTopic.has(full)) byTopic.set(full, []);
byTopic.get(full).push(r);
}
// ----- MQTT -----
const client = mqtt.connect(import.meta.env.VITE_MQTT_URL, {
clientId: import.meta.env.VITE_MQTT_CLIENT_ID || "web-twin-" + crypto.randomUUID().slice(0, 8),
username: import.meta.env.VITE_MQTT_USERNAME || undefined,
password: import.meta.env.VITE_MQTT_PASSWORD || undefined,
protocolVersion: 5,
reconnectPeriod: 2000,
});
let connected = false;
client.on("connect", () => { connected = true; client.subscribe(`${PREFIX}/#`, { qos: 0 }); });
client.on("close", () => (connected = false));
client.on("message", (topic, payload) => {
const targets = byTopic.get(topic);
if (!targets) return;
let data;
try { data = JSON.parse(new TextDecoder().decode(payload)); } catch { return; }
for (const r of targets) {
if (!(r.field in data)) continue;
r.target = data[r.field];
r.last = data;
r.at = Date.now();
}
});
// ----- Comportamientos -----
const tmp = new THREE.Color();
const behaviours = {
heat(r, k) {
r.current += (r.target - r.current) * k;
const t = THREE.MathUtils.clamp((r.current - r.min) / (r.max - r.min), 0, 1);
r.obj.traverse((o) => {
if (!o.isMesh) return;
o.material.emissive.setRGB(1, 0.35, 0.05);
o.material.emissiveIntensity = t * 1.5;
});
},
fill(r, k) {
r.current += (r.target - r.current) * k;
r.obj.scale.y = Math.max(THREE.MathUtils.clamp(r.current, 0, 1) * 2.8, 0.001);
},
spin(r, k, dt) {
r.current += (r.target - r.current) * k;
r.obj.rotation[r.axis] += (r.current / 60) * Math.PI * 2 * dt * 0.05;
},
status(r) {
const stale = Date.now() - r.at > 10000;
tmp.setHex(stale ? statusColors.OFFLINE : statusColors[r.target] ?? statusColors.OFFLINE);
r.obj.traverse((o) => {
if (!o.isMesh) return;
o.material.color.copy(tmp);
o.material.emissive.copy(tmp);
o.material.emissiveIntensity = 0.6;
});
},
};
// ----- Clic para inspeccionar -----
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let selected = null;
renderer.domElement.addEventListener("pointerdown", (e) => {
pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(root.children, true)[0];
selected = hit ? runtime.find((r) => r.obj && (r.obj === hit.object || r.obj.getObjectById(hit.object.id))) : null;
});
// ----- Bucle de renderizado -----
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
const k = 1 - Math.exp(-dt * 4);
for (const r of runtime) if (r.obj) behaviours[r.kind](r, k, dt);
const head = connected ? "MQTT connected" : "MQTT disconnected";
panel.textContent = selected
? `${head}\n\n${selected.node}\n${JSON.stringify(selected.last, null, 2)}`
: `${head}\nClick a machine for details`;
controls.update();
renderer.render(scene, camera);
});
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
Ejecuta npm run dev y abre la URL local. Después, aliméntalo con datos desde una terminal:
P=connectmq/demo/factory-7f3a
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/furnace1/telemetry" -m '{"temperature":640}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/tank1/telemetry" -m '{"level":0.7}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/pump1/telemetry" -m '{"rpm":1800}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/cnc1/state" -m '{"state":"RUNNING"}'
El flag -r publica retained messages, de modo que aunque recargues la página más tarde, el gemelo muestra de inmediato los últimos valores conocidos en lugar de una fábrica vacía. Ese único flag marca la diferencia entre un gemelo que necesita "calentarse" y uno que es correcto desde el primer fotograma; las ventajas e inconvenientes se analizan en retained messages MQTT para gemelos digitales.
Decisiones de diseño que compensan
Configuración antes que código. Cuando la planta incorpora un segundo horno, añades una entrada a bindings.js y una malla llamada Furnace_2. El runtime no cambia, lo que además facilita generar la configuración más adelante a partir de una base de datos de activos.
Materiales propios para cada parte vinculada. Los exportadores glTF suelen compartir un mismo material entre muchas mallas. Sin el paso clone(), hacer brillar un horno haría brillar todos los objetos que usan ese material.
Un estado que puede quedar obsoleto. El comportamiento status se vuelve gris si no llega ningún mensaje de estado durante diez segundos. Un gemelo nunca debe mostrar "en marcha" para una máquina cuyo gateway dejó de funcionar hace una hora.
Una sola suscripción. Una única suscripción con comodín bajo el prefijo mantiene al cliente muy por debajo del límite del servidor público de 10 suscripciones por cliente, y el mapa de enrutamiento reparte los mensajes por topic localmente.
Para profundizar en la capa de mapeo, incluidos morph targets y clips de animación, consulta vincular datos IoT a un modelo 3D. Para colocar tarjetas de KPI y gráficos junto a la vista 3D, continúa con el tutorial del dashboard de gemelo digital.
Paso 6: compila y despliega
El gemelo es un sitio estático, así que el alojamiento es sencillo:
npm run build # genera dist/
npx serve dist # comprobación local de la compilación de producción
Sube dist/ a cualquier hosting estático o almacenamiento de objetos con CDN. Antes de hacerlo, repasa esta lista:
- Apunta
VITE_MQTT_URLa tu broker privado y define un prefijo de topic de producción. - Nunca incluyas credenciales de dispositivos en el bundle. El navegador recibe sus propias credenciales y la página queda detrás de tu inicio de sesión.
- Sirve el sitio por HTTPS; los navegadores bloquean las conexiones
ws://desde páginas HTTPS, y por eso el gemelo usawss://en el puerto 8084. - Comprime el modelo y comprueba el tiempo de carga con una conexión lenta.
- Prueba qué ocurre cuando el broker no es accesible: el panel debe mostrar "MQTT disconnected" y los estados deben ponerse grises.
Traslada el gemelo a tu propio broker
El servidor público gratuito es ideal para pruebas, prototipos y demos, pero todo lo que contiene es visible para cualquiera y nunca debe usarse con datos de producción. Para una planta real, un broker MQTT gestionado te da credenciales, TLS y aislamiento sin tener que operar servidores. Con ConnectMQ creas un broker en el portal, copias el nombre de usuario, la contraseña y el client ID generados, y añades VITE_MQTT_USERNAME, VITE_MQTT_PASSWORD, VITE_MQTT_CLIENT_ID y la URL a tu entorno de producción. El broker también detecta jerarquías de topics al estilo ISA-95 y puede republicar datos Sparkplug B en topics ISA-95 legibles, lo que proporciona a un gemelo web topics limpios y aptos para JSON a los que vincularse incluso cuando la planta habla Sparkplug.
Empieza con ConnectMQ
La forma más rápida de ver un resultado terminado es la demo web de gemelo digital 3D de ConnectMQ, que transmite datos en vivo a una escena de fábrica a través de WebSocket seguro. Cuando estés listo para ejecutar tu propio gemelo digital 3D web, regístrate en app.connectmq.com para obtener el plan Serverless gratuito, sin tarjeta de crédito, y apunta VITE_MQTT_URL a tu nuevo broker. ¿Primero tienes que elegir el modelo de alojamiento? Nuestra guía sobre el broker MQTT en la nube compara las opciones.
Preguntas frecuentes
¿Qué formato 3D es mejor para un gemelo digital web?
glTF binario (.glb) es el estándar para la web: compacto, rápido de cargar y compatible de forma nativa con Three.js y Babylon.js. Convierte formatos CAD como STEP o FBX a glTF y simplifícalos antes de usarlos.
¿Cómo muestra el gemelo valores correctos justo después de cargar la página?
Los dispositivos publican su último estado como retained messages, así que el broker entrega el último valor de cada topic en cuanto el navegador se suscribe. Sin retained messages, el gemelo permanece vacío hasta que llega la siguiente actualización.
¿Puedo controlar máquinas desde un gemelo digital 3D web?
Sí, publicando en topics de comandos desde la página, por ejemplo cuando un usuario hace clic en una máquina. Trata los comandos con cuidado: exige confirmación, usa QoS 1 y deja que el dispositivo publique de vuelta su estado real.
¿Funciona un gemelo en el navegador en tablets?
Sí. WebGL funciona en los navegadores móviles modernos. Mantén moderados el número de polígonos y el tamaño de las texturas, y limita el pixel ratio como hace el ejemplo para conservar una tasa de fotogramas fluida.



