Para vincular dados IoT a um modelo 3D, você precisa de uma camada pequena e bem definida que receba uma mensagem de um tópico, extraia um valor dela, transforme-o e aplique-o a uma parte específica do modelo. Este guia constrói essa camada para modelos glTF no Three.js: você vai mapear valores MQTT para transformações, visibilidade, cores de material, morph targets e clipes de animação, e armazenar as definições de vínculo dentro do próprio modelo usando glTF extras. Os dados vêm do servidor MQTT público em broker.connectmq.com, então você pode testar cada vínculo a partir de um terminal.
A Anatomia de um Vínculo
Todo vínculo, independentemente do motor, responde a cinco perguntas:
| Pergunta | Exemplo | Campo no nosso vínculo |
|---|---|---|
| Qual tópico? | connectmq/demo/bind-7f3a/press1/telemetry |
topic (suporta + e #) |
| Qual valor do payload? | hydraulics.pressure |
path |
| Como ele é transformado? | 0 a 250 bar vira 0 a 1 | map: [inMin, inMax, outMin, outMax], clamp |
| Qual parte do modelo? | Nó chamado Press_Ram |
node |
| Qual propriedade muda? | Posição em Y, cor emissiva, velocidade da animação | kind e suas opções |
Registrar isso como dados, e não como código, é a decisão mais importante em um gêmeo. O código contém alguns "tipos" genéricos; os dados dizem qual tópico controla qual parte. Quando um engenheiro adiciona um sensor, ninguém edita JavaScript. Se você nunca construiu um gêmeo, comece pelo tutorial de gêmeo digital com Three.js, volte aqui para a camada de mapeamento e consulte o guia de tecnologia de gêmeo digital para o contexto mais amplo.
Prepare o Modelo glTF para o Vínculo de Dados
Um modelo exportado para renders de marketing raramente está pronto para ser vinculado a dados. Corrija estes pontos no Blender (ou na sua ferramenta DCC) antes de exportar:
- Nomes de nós únicos e estáveis.
Press_Ram,Press_Beacon,Tank_Liquid. Evite nomes comoCylinder.014, que mudam a cada nova exportação. - Pivôs corretos. Um braço giratório precisa ter a origem na articulação; um nível de enchimento precisa ter a origem na base.
- Objetos separados para as partes vinculadas. Se o sinalizador estiver unido ao corpo da máquina, mudar a cor dele pinta a máquina inteira.
- Morph targets (shape keys) para deformações contínuas, como uma mangueira se esticando ou o mostrador de um manômetro, e clipes de animação (actions) para movimentos cíclicos, como uma esteira ou um ventilador.
- Propriedades personalizadas para os vínculos. O Blender exporta as propriedades personalizadas dos objetos como
extrasdo glTF quando "Include > Custom Properties" está ativado, e o Three.js as coloca emobject.userData.
Esse último ponto permite que o artista 3D e o engenheiro de automação trabalhem no mesmo arquivo. Adicione uma propriedade personalizada chamada binding a Press_Ram com uma string JSON como valor:
{
"topic": "connectmq/demo/bind-7f3a/press1/telemetry",
"path": "ram.position",
"map": [0, 400, 0, -1.2],
"clamp": true,
"kind": "translate",
"axis": "y"
}
Após a exportação e o carregamento, object.userData.binding contém essa string.
Tipos de Vínculo Que Cobrem a Maioria dos Gêmeos
Você precisa de surpreendentemente poucos tipos. Estes seis cobrem a maior parte das visualizações industriais:
| Tipo | Aplica-se a | Dados típicos | Implementação |
|---|---|---|---|
translate |
Posição do nó em um eixo | Curso de cilindro, altura de elevação | Deslocamento a partir da posição de repouso |
rotate |
Rotação do nó em um eixo | Abertura de válvula, ângulo do braço | Ângulo a partir da rotação de repouso |
spin |
Rotação contínua | Rpm do motor, velocidade do ventilador | Somar value * dt a cada quadro |
color |
Cor do material ou emissiva | Temperatura, enum de status | Gradiente ou tabela de consulta |
morph |
Influência do morph target | Nível de enchimento, deformação | morphTargetInfluences[i] |
clip |
Velocidade ou peso do clipe de animação | Velocidade da esteira, ciclo em execução | AnimationAction.timeScale |
A visibilidade é um caso especial de consultas parecidas com color (visible: true/false a partir de um booleano), e rótulos de texto costumam funcionar melhor como sobreposições HTML do que como texto 3D.
O Motor de Vínculos
O módulo abaixo carrega um glTF, coleta os vínculos de userData, compara os tópicos MQTT recebidos (inclusive com curingas), extrai e transforma os valores e os aplica a cada quadro. Salve-o como binding-engine.js; ele pressupõe que o Three.js e o MQTT.js estejam disponíveis por meio de um import map ou de um bundler, como nos tutoriais anteriores.
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
// ---------- funções auxiliares ----------
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; // strings, cores, booleanos: sem suavização
}
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 };
}
Conecte-o a uma página:
import * as THREE from "three";
import mqtt from "mqtt";
import { createTwin } from "./binding-engine.js";
// cena, câmera e renderer configurados como de costume ...
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);
});
Assinar exatamente os filtros que o modelo declara, em vez de #, mantém o tráfego baixo. No servidor MQTT público, respeite o limite de 10 assinaturas por cliente; se um modelo declarar mais filtros do que isso, assine um nível pai comum com um curinga.
Mais Exemplos de Vínculo
O mesmo motor lida com partes muito diferentes usando apenas dados. Um sinalizador de status controlado por um enum, usando uma tabela de consulta e o canal emissivo:
{
"topic": "connectmq/demo/bind-7f3a/press1/state",
"path": "state",
"lookup": { "RUNNING": "#22c55e", "IDLE": "#eab308", "FAULT": "#ef4444", "default": "#6b7280" },
"kind": "color",
"channel": "emissive"
}
Um tanque cujo nível é um morph target chamado Fill, alimentado por uma porcentagem:
{ "topic": "connectmq/demo/bind-7f3a/tank1/telemetry", "path": "level_pct",
"map": [0, 100, 0, 1], "clamp": true, "kind": "morph", "morph": "Fill" }
Uma animação de correia transportadora cuja velocidade acompanha a frequência do inversor (50 Hz reproduz o clipe na velocidade normal):
{ "topic": "connectmq/demo/bind-7f3a/conv1/telemetry", "path": "drive.hz",
"map": [0, 50, 0, 1], "clamp": true, "kind": "clip", "clip": "BeltLoop" }
Teste qualquer vínculo diretamente de um 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"}'
A flag -r retém a mensagem, então o modelo mostra o valor assim que carrega. O estado retido é a espinha dorsal de um gêmeo que está correto já na primeira renderização; mensagens MQTT retidas para gêmeos digitais discute quando usá-lo.
Armadilhas ao Vincular Dados IoT a um Modelo 3D
- Materiais compartilhados. Exportadores glTF reutilizam materiais. Clone-os em qualquer parte cuja cor esteja vinculada, ou um único alarme deixará a fábrica inteira vermelha.
- Transformações absolutas versus relativas. Aplique sempre os valores em relação à pose de repouso capturada no carregamento. Caso contrário, a parte salta para a origem do mundo.
- Graus versus radianos. Sensores informam graus; o Three.js espera radianos. Converta em um único lugar, como faz
rotate. - Valores ausentes. Payloads evoluem. Se
pathresultar emundefined, mantenha o valor anterior em vez de saltar para zero. - Dados desatualizados. Registre quando cada vínculo foi atualizado pela última vez e esmaeça as partes que não recebem notícias do dispositivo há algum tempo.
- Nó renomeado no modelo. Registre um aviso para cada vínculo cujo nó ou clipe não seja encontrado no carregamento; falhas silenciosas são as mais difíceis de depurar.
- Suavização em excesso. A suavização combina com valores contínuos, mas esconde eventos discretos. Não suavize enums, booleanos nem estados de alarme.
Os orçamentos de latência para cada uma dessas etapas são abordados em latência de gêmeos digitais em tempo real.
De Onde Vêm os Dados
Os vínculos são tão bons quanto os tópicos por trás deles. Uma hierarquia consistente, como site/area/line/cell/asset/metric, permite vincular com curingas e reutilizar modelos entre linhas. Se a sua planta, seja uma fábrica em Manaus ou uma linha em Joinville, publica Sparkplug B, a ConnectMQ pode decodificá-lo e republicá-lo em tópicos legíveis no estilo ISA-95, como connectmq/factory1/production/line1/cnc1/telemetry, que são muito mais fáceis de vincular do que payloads Protobuf. O guia de Sparkplug B e Unified Namespace explica essa estrutura. Para uma aplicação completa que usa este motor com configuração e deploy, veja como construir um gêmeo digital 3D na web, ou siga o tutorial de gêmeo digital de esteira transportadora para um exemplo focado.
Comece com a ConnectMQ
Quando seus vínculos funcionarem no servidor MQTT público, migre-os para um privado. Um broker MQTT gerenciado da ConnectMQ oferece WebSocket seguro na porta 8084 com um certificado confiável, além de um explorador de tópicos ao vivo que mostra exatamente quais caminhos existem nos seus payloads, algo valioso na hora de escrever vínculos. Crie um broker gratuito em app.connectmq.com; o plano Serverless não exige cartão de crédito. Continue usando o servidor MQTT público (gratuito) para experimentos rápidos com dados não sensíveis, nunca para dados de produção.
Perguntas frequentes
Onde devem ficar as definições de vínculo: no modelo ou no código?
As duas opções funcionam. Armazená-las como glTF extras mantém o modelo e o mapeamento de dados juntos para equipes pequenas, enquanto um arquivo JSON de configuração separado é mais fácil de gerar a partir de um banco de dados de ativos e de revisar no controle de versão.
Posso vincular dados a animações exportadas do Blender?
Sim. As actions exportadas se tornam clipes de animação glTF. Reproduza-as por meio de um AnimationMixer e vincule o timeScale da action para controlar a velocidade, ou o seu peso para mesclar estados.
Como vinculo dados a uma parte que não tem malha separada?
Não é possível mover ou mudar a cor de parte de uma malha mesclada de forma independente. Separe a parte em um objeto próprio na sua ferramenta de modelagem e exporte novamente, ou use um morph target se a mudança for uma deformação.
Essa abordagem funciona no Babylon.js ou no Unity?
O modelo de vínculo com tópico, caminho, transformação, nó e tipo é independente de motor. Só a etapa de aplicação muda, usando as APIs próprias de cada motor para transformações, materiais e animações.



