Gemelo digital

Cómo vincular datos IoT a un modelo 3D: mallas, materiales y animaciones glTF

Guía práctica sobre la capa de mapeo de un gemelo digital 3D: cómo vincular datos IoT a un modelo 3D asignando valores de topics MQTT a nodos, materiales, morph targets y clips de animación glTF, con las vinculaciones guardadas en el propio modelo.

Diagrama que muestra cómo vincular datos IoT a un modelo 3D con topics MQTT asignados a mallas y materiales glTF
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

Para vincular datos IoT a un modelo 3D necesitas una capa pequeña y bien definida que tome un mensaje de un topic, extraiga un valor, lo transforme y lo aplique a una parte concreta del modelo. Esta guía construye esa capa, el núcleo de cualquier gemelo digital 3D, para modelos glTF en Three.js: asignarás valores MQTT a transformaciones, visibilidad, colores de material, morph targets y clips de animación, y guardarás las definiciones de vinculación dentro del propio modelo mediante los extras de glTF. Los datos llegan desde broker.connectmq.com, el servidor MQTT público gratuito de ConnectMQ para pruebas, así que puedes probar cada vinculación desde una terminal.

Anatomía de una vinculación

Toda vinculación, sea cual sea el motor, responde a cinco preguntas:

Pregunta Ejemplo Campo en nuestra vinculación
¿Qué topic? connectmq/demo/bind-7f3a/press1/telemetry topic (admite + y #)
¿Qué valor del payload? hydraulics.pressure path
¿Cómo se transforma? De 0 a 250 bar pasa a 0 a 1 map: [inMin, inMax, outMin, outMax], clamp
¿Qué parte del modelo? Nodo llamado Press_Ram node
¿Qué propiedad cambia? Posición en Y, color emisivo, velocidad de animación kind y sus opciones

Escribirlas como datos y no como código es la decisión más importante de un gemelo. El código contiene unos pocos "tipos" genéricos; los datos dicen qué topic controla qué parte. Cuando un ingeniero añade un sensor, nadie edita JavaScript. Si nunca has construido un gemelo, empieza por el tutorial de gemelo digital con Three.js, vuelve aquí para la capa de mapeo y consulta la guía de tecnología de gemelo digital para el contexto general.

Prepara el modelo glTF para la vinculación de datos

Un modelo exportado para renders de marketing rara vez está listo para vincularse a datos. Corrige estos aspectos en Blender (o tu herramienta DCC) antes de exportar:

  1. Nombres de nodo únicos y estables. Press_Ram, Press_Beacon, Tank_Liquid. Evita nombres como Cylinder.014, que cambian al volver a exportar.
  2. Pivotes correctos. Un brazo que gira necesita el origen en la articulación; un nivel de llenado necesita el origen en la base.
  3. Objetos separados para las partes vinculadas. Si la baliza está unida al cuerpo de la máquina, cambiar su color colorea la máquina entera.
  4. Morph targets (shape keys) para deformaciones continuas, como una manguera que se estira o la esfera de un indicador, y clips de animación (actions) para movimientos cíclicos, como una cinta transportadora o un ventilador.
  5. Propiedades personalizadas para las vinculaciones. Blender exporta las propiedades personalizadas de los objetos como extras de glTF cuando "Include > Custom Properties" está activado, y Three.js las coloca en object.userData.

Este último punto permite que el artista 3D y el ingeniero de automatización trabajen sobre un mismo archivo. Añade a Press_Ram una propiedad personalizada llamada binding cuyo valor sea una cadena JSON:

{
  "topic": "connectmq/demo/bind-7f3a/press1/telemetry",
  "path": "ram.position",
  "map": [0, 400, 0, -1.2],
  "clamp": true,
  "kind": "translate",
  "axis": "y"
}

Tras exportar y cargar el modelo, object.userData.binding contiene esa cadena.

Tipos de vinculación que cubren la mayoría de gemelos

Hacen falta sorprendentemente pocos tipos. Estos seis cubren la mayor parte de las visualizaciones industriales:

Tipo Se aplica a Datos típicos Implementación
translate Posición del nodo en un eje Carrera de un cilindro, altura de elevación Desplazamiento desde la posición de reposo
rotate Rotación del nodo en un eje Apertura de una válvula, ángulo de un brazo Ángulo desde la rotación de reposo
spin Rotación continua rpm de un motor, velocidad de un ventilador Sumar value * dt en cada fotograma
color Color del material o emisivo Temperatura, enumeración de estado Degradado o tabla de consulta
morph Influencia de un morph target Nivel de llenado, deformación morphTargetInfluences[i]
clip Velocidad o peso de un clip de animación Velocidad de la cinta, ciclo en marcha AnimationAction.timeScale

La visibilidad es un caso especial de consultas similares a color (visible: true/false a partir de un booleano), y las etiquetas de texto suelen funcionar mejor como superposiciones HTML que como texto 3D.

El motor de vinculaciones

El módulo siguiente carga un glTF, recoge las vinculaciones de userData, compara los topics MQTT entrantes (incluidos los comodines), extrae y transforma los valores y los aplica en cada fotograma. Guárdalo como binding-engine.js; da por hecho que Three.js y MQTT.js están disponibles mediante un import map o un bundler, como en los tutoriales anteriores.

import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

// ---------- utilidades ----------
export function topicMatches(filter, topic) {
  const f = filter.split("/"), t = topic.split("/");
  for (let i = 0; i < f.length; i++) {
    if (f[i] === "#") return true;
    if (i >= t.length) return false;
    if (f[i] !== "+" && f[i] !== t[i]) return false;
  }
  return f.length === t.length;
}

const getPath = (obj, path) =>
  path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);

function transform(value, b) {
  if (b.lookup) return b.lookup[value] ?? b.lookup.default;
  let v = Number(value);
  if (Number.isNaN(v)) return undefined;
  if (b.map) {
    const [a, c, d, e] = b.map;
    v = d + ((v - a) / (c - a)) * (e - d);
    if (b.clamp) v = THREE.MathUtils.clamp(v, Math.min(d, e), Math.max(d, e));
  }
  return v;
}

// ---------- motor ----------
export async function createTwin(scene, url) {
  const gltf = await new GLTFLoader().loadAsync(url);
  scene.add(gltf.scene);
  const mixer = new THREE.AnimationMixer(gltf.scene);
  const bindings = [];

  gltf.scene.traverse((obj) => {
    if (!obj.userData.binding) return;
    let b;
    try { b = JSON.parse(obj.userData.binding); } catch { console.warn("Bad binding on", obj.name); return; }
    b.obj = obj;
    b.rest = { position: obj.position.clone(), rotation: obj.rotation.clone() };
    b.target = b.current = undefined;
    if (b.kind === "color") {
      obj.traverse((o) => { if (o.isMesh) o.material = o.material.clone(); });
    }
    if (b.kind === "clip") {
      const clip = THREE.AnimationClip.findByName(gltf.animations, b.clip);
      if (clip) { b.action = mixer.clipAction(clip); b.action.play(); b.action.timeScale = 0; }
      else console.warn(`Clip ${b.clip} not found`);
    }
    bindings.push(b);
  });

  function onMessage(topic, payload) {
    let data;
    const text = new TextDecoder().decode(payload);
    try { data = JSON.parse(text); } catch { data = text; }
    for (const b of bindings) {
      if (!topicMatches(b.topic, topic)) continue;
      const raw = b.path ? getPath(data, b.path) : data;
      const v = transform(raw, b);
      if (v !== undefined) b.target = v;
    }
  }

  const color = new THREE.Color();
  function update(dt) {
    const k = 1 - Math.exp(-dt * 5);
    for (const b of bindings) {
      if (b.target === undefined) continue;
      if (typeof b.target === "number") {
        b.current = b.current === undefined ? b.target : b.current + (b.target - b.current) * k;
      } else {
        b.current = b.target; // cadenas, colores, booleanos: sin suavizado
      }
      const o = b.obj, v = b.current;
      switch (b.kind) {
        case "translate": o.position[b.axis] = b.rest.position[b.axis] + v; break;
        case "rotate":    o.rotation[b.axis] = b.rest.rotation[b.axis] + THREE.MathUtils.degToRad(v); break;
        case "spin":      o.rotation[b.axis] += v * dt; break;
        case "visible":   o.visible = Boolean(v); break;
        case "color":
          color.set(typeof v === "number"
            ? new THREE.Color(b.from).lerp(new THREE.Color(b.to), v)
            : v);
          o.traverse((m) => {
            if (!m.isMesh) return;
            m.material[b.channel ?? "color"].copy(color);
          });
          break;
        case "morph":
          o.traverse((m) => {
            if (m.morphTargetInfluences && m.morphTargetDictionary?.[b.morph] !== undefined) {
              m.morphTargetInfluences[m.morphTargetDictionary[b.morph]] = v;
            }
          });
          break;
        case "clip": if (b.action) b.action.timeScale = v; break;
      }
    }
    mixer.update(dt);
  }

  const filters = [...new Set(bindings.map((b) => b.topic))];
  return { bindings, filters, onMessage, update };
}

Conéctalo a una página:

import * as THREE from "three";
import mqtt from "mqtt";
import { createTwin } from "./binding-engine.js";

// escena, cámara y renderer configurados como de costumbre ...
const twin = await createTwin(scene, "/models/press.glb");

const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
  clientId: "bind-viewer-" + crypto.randomUUID().slice(0, 8),
  protocolVersion: 5,
});
client.on("connect", () => client.subscribe(twin.filters, { qos: 0 }));
client.on("message", twin.onMessage);

const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
  twin.update(clock.getDelta());
  renderer.render(scene, camera);
});

Suscribirse exactamente a los filtros que declara el modelo, en lugar de a #, mantiene bajo el tráfico. En el servidor público, ten en cuenta el límite de 10 suscripciones por cliente; si un modelo declara más filtros, suscríbete a un padre común con un comodín.

Más ejemplos de vinculación

El mismo motor gestiona piezas muy distintas solo con datos. Una baliza de estado controlada por una enumeración, con una tabla de consulta y el canal emisivo:

{
  "topic": "connectmq/demo/bind-7f3a/press1/state",
  "path": "state",
  "lookup": { "RUNNING": "#22c55e", "IDLE": "#eab308", "FAULT": "#ef4444", "default": "#6b7280" },
  "kind": "color",
  "channel": "emissive"
}

Un tanque cuyo nivel es un morph target llamado Fill, alimentado con un porcentaje:

{ "topic": "connectmq/demo/bind-7f3a/tank1/telemetry", "path": "level_pct",
  "map": [0, 100, 0, 1], "clamp": true, "kind": "morph", "morph": "Fill" }

La animación de una cinta transportadora cuya velocidad sigue la frecuencia del accionamiento (a 50 Hz el clip se reproduce a velocidad normal):

{ "topic": "connectmq/demo/bind-7f3a/conv1/telemetry", "path": "drive.hz",
  "map": [0, 50, 0, 1], "clamp": true, "kind": "clip", "clip": "BeltLoop" }

Prueba cualquier vinculación directamente desde una terminal:

mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-bind-7f3a -r \
  -t "connectmq/demo/bind-7f3a/press1/telemetry" -m '{"ram":{"position":220}}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-bind-7f3a -r \
  -t "connectmq/demo/bind-7f3a/press1/state" -m '{"state":"FAULT"}'

El flag -r retiene el mensaje, de modo que el modelo muestra el valor en cuanto se carga. El estado retenido es la columna vertebral de un gemelo correcto desde el primer fotograma; retained messages MQTT para gemelos digitales explica cuándo usarlo.

Errores habituales al vincular datos IoT a un modelo 3D

  1. Materiales compartidos. Los exportadores glTF reutilizan materiales. Clónalos en cualquier parte cuyo color esté vinculado, o una sola alarma teñirá de rojo toda la fábrica.
  2. Transformaciones absolutas frente a relativas. Aplica siempre los valores en relación con la pose de reposo capturada al cargar. De lo contrario, la parte salta al origen del mundo.
  3. Grados frente a radianes. Los sensores informan en grados; Three.js espera radianes. Convierte en un único sitio, como hace rotate.
  4. Valores ausentes. Los payloads evolucionan. Si path se resuelve como undefined, conserva el valor anterior en lugar de saltar a cero.
  5. Datos obsoletos. Registra cuándo se actualizó por última vez cada vinculación y atenúa las partes que llevan un tiempo sin recibir noticias de su dispositivo.
  6. Nodo renombrado en el modelo. Registra una advertencia por cada vinculación cuyo nodo o clip no se encuentre al cargar; los fallos silenciosos son los más difíciles de depurar.
  7. Demasiado suavizado. El suavizado encaja con valores continuos, pero oculta eventos discretos. No suavices enumeraciones, booleanos ni estados de alarma.

Los presupuestos de latencia de cada una de estas etapas se tratan en latencia del gemelo digital en tiempo real.

De dónde vienen los datos

Las vinculaciones solo son tan buenas como los topics que hay detrás. Una jerarquía coherente como site/area/line/cell/asset/metric permite vincular con comodines y reutilizar modelos entre líneas. Si tu planta publica Sparkplug B, ConnectMQ puede decodificarlo y republicarlo en topics legibles al estilo ISA-95, como connectmq/factory1/production/line1/cnc1/telemetry, mucho más fáciles de vincular que los payloads Protobuf. La guía de Sparkplug B y Unified Namespace explica esa estructura. Para una aplicación completa que usa este motor con configuración y despliegue, consulta cómo crear un gemelo digital 3D web, o sigue el tutorial de gemelo digital de una cinta transportadora para un ejemplo concreto.

Empieza con ConnectMQ

Cuando tus vinculaciones funcionen en el servidor público, trasládalas a uno privado. Un broker MQTT gestionado de ConnectMQ ofrece WebSocket seguro en el puerto 8084 con un certificado de confianza, además de un explorador de topics en vivo que muestra exactamente qué rutas existen en tus payloads, algo muy valioso al escribir vinculaciones. Crea un broker gratuito en app.connectmq.com; el plan Serverless no requiere tarjeta de crédito. Sigue usando el servidor MQTT público gratuito para experimentos rápidos con datos no sensibles, nunca para datos de producción.

Preguntas frecuentes

¿Dónde deben vivir las definiciones de vinculación: en el modelo o en el código?

Ambas opciones funcionan. Guardarlas como extras de glTF mantiene juntos el modelo y el mapeo de datos en equipos pequeños, mientras que una configuración JSON aparte es más fácil de generar a partir de una base de datos de activos y de revisar en el control de versiones.

¿Puedo vincular datos a animaciones exportadas desde Blender?

Sí. Las actions exportadas se convierten en clips de animación glTF. Reprodúcelas con un AnimationMixer y vincula el timeScale de la acción para la velocidad, o su peso para mezclar entre estados.

¿Cómo vinculo datos a una parte que no tiene una malla propia?

No puedes mover ni cambiar de color una parte de una malla fusionada de forma independiente. Separa la parte en su propio objeto en tu herramienta de modelado y vuelve a exportar, o usa un morph target si el cambio es una deformación.

¿Este enfoque funciona en Babylon.js o Unity?

El modelo de vinculación formado por topic, ruta, transformación, nodo y tipo es independiente del motor. Solo cambia el paso de aplicación, que usa las API propias de cada motor para transformaciones, materiales y animaciones.

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.