Um gêmeo digital 3D na web permite que qualquer pessoa com um navegador veja o estado ao vivo de uma máquina, de uma linha ou de uma planta inteira, sem instalar software. Este guia mostra como construir um da maneira certa: preparar um modelo glTF, configurar um projeto Vite, vincular tópicos MQTT a partes do modelo por meio de um arquivo de configuração, adicionar detalhes ao clicar e publicar o resultado como um site estático. Os dados vêm do servidor MQTT público em broker.connectmq.com via WebSocket seguro, a mesma abordagem usada na própria demo de fábrica 3D da ConnectMQ.
Arquitetura de um Gêmeo Digital 3D na Web
Antes de escrever código, vale a pena enxergar o sistema completo. Um gêmeo no navegador é, em grande parte, uma aplicação front-end; o broker faz o trabalho pesado de transportar os dados.
| Camada | Responsabilidade | Tecnologia neste guia |
|---|---|---|
| Dispositivos e gateways | Publicar telemetria e estado | Qualquer cliente MQTT (gateway de CLP, ESP32, Python) |
| Broker | Rotear mensagens, manter o estado retido | Broker MQTT gerenciado, WSS na porta 8084 |
| Modelo | Geometria, partes nomeadas, materiais | glTF binário (.glb) |
| Configuração de vínculos | Qual campo de qual tópico controla qual parte, e como | Um módulo de configuração JavaScript |
| Runtime | Carregar o modelo, assinar, animar, interagir | Three.js e MQTT.js em um app Vite |
A ideia central é a configuração de vínculos. Em vez de escrever código para cada máquina, você escreve alguns comportamentos genéricos (brilho de calor, nível de enchimento, rotação, cor de status) e uma lista que conecta tópicos a partes do modelo. Adicionar uma nova bomba passa a significar adicionar uma linha de configuração e uma malha nomeada, sem mexer no runtime. Se o conceito é novo para você, a visão geral da tecnologia de gêmeo digital explica como gêmeos visuais se relacionam com gêmeos de simulação e de análise.
Passo 1: Planeje Ativos e Tópicos
Uma cena típica de fábrica, como a da demo mencionada acima, tem uma máquina CNC, um braço robótico, um forno, um tanque, uma esteira e uma bomba. Usaremos quatro deles como exemplo. Escreva o plano primeiro; ele se tornará seu arquivo de configuração.
| Ativo | Tópico (sob seu prefixo) | Campo | Comportamento visual |
|---|---|---|---|
| Forno | furnace1/telemetry |
temperature (20 a 900) |
Brilho emissivo do escuro ao laranja |
| Tanque | tank1/telemetry |
level (0 a 1) |
A malha do líquido escala no eixo Y |
| Bomba | pump1/telemetry |
rpm (0 a 3000) |
O rotor gira |
| Máquina CNC | cnc1/state |
state |
Cor do sinalizador: em operação, ociosa, alarme |
Para a demo, usamos o prefixo connectmq/demo/factory-7f3a; substitua o sufixo por uma string aleatória sua. Em produção, você usaria um namespace hierárquico como acme/plant1/area2/line1/furnace1, e nosso artigo sobre MQTT para gêmeos digitais explica como projetá-lo.
Passo 2: Prepare o Modelo 3D
Qualquer que seja a origem do seu modelo (Blender, uma exportação de CAD, um asset comprado), deixe-o pronto para o gêmeo antes de exportar:
- Dê a cada parte vinculada um nome único e significativo, por exemplo
Furnace,Tank_Liquid,Pump_Impeller,CNC_Beacon. Os nomes sobrevivem à exportação glTF e são a forma como o runtime encontra as partes. - Separe as partes móveis em objetos próprios, com o pivô no centro de rotação. Um rotor mesclado à carcaça da bomba não consegue girar sozinho.
- Dê ao líquido uma altura unitária com a origem na base, para que a escala no eixo Y encha de baixo para cima.
- Reduza polígonos em tudo o que não for visualmente importante. Exportações de CAD costumam ter milhões de triângulos; um gêmeo no navegador geralmente deve ficar bem abaixo disso.
- Exporte como glTF binário (
.glb) com "Apply modifiers" ativado. Opcionalmente, comprima com Draco ou Meshopt para um carregamento mais rápido.
O código abaixo inclui um fallback que cria caixas simples com os mesmos nomes caso nenhum modelo seja encontrado, para que você possa acompanhar sem um modelo.
Passo 3: Crie a Estrutura do Projeto
npm create vite@latest web-twin -- --template vanilla
cd web-twin
npm install three mqtt
mkdir -p public/models # coloque factory.glb aqui
O runtime abaixo aguarda o modelo no nível superior de um módulo, então diga ao Vite para ter navegadores modernos como alvo criando vite.config.js:
export default { build: { target: "es2022" } };
Crie um arquivo .env para configurações que mudam entre ambientes. O Vite expõe ao navegador as variáveis com o prefixo VITE_:
VITE_MQTT_URL=wss://broker.connectmq.com:8084/mqtt
VITE_TOPIC_PREFIX=connectmq/demo/factory-7f3a
Passo 4: Escreva a Configuração de Vínculos
Crie src/bindings.js. Cada entrada informa qual tópico e campo ler, qual parte do modelo alterar e qual comportamento 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,
};
Passo 5: O Runtime do Gêmeo
Substitua src/main.js pelo runtime abaixo. Ele carrega o modelo (ou cria marcadores de posição), clona os materiais para que as partes possam mudar de cor de forma independente, conecta-se ao MQTT, encaminha as mensagens pelos vínculos e anima tudo em um único loop de renderização.
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);
// ----- Carregamento do modelo com fallback de marcadores -----
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); // origem na 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);
// ----- Resolve os vínculos para objetos e dá a eles materiais próprios -----
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();
}
});
// ----- Comportamentos -----
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;
});
},
};
// ----- Clique para inspecionar -----
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;
});
// ----- Loop de renderização -----
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);
});
Execute npm run dev e abra a URL local. Em seguida, alimente-o com dados a partir de um 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"}'
A flag -r publica mensagens retidas, então, mesmo que você recarregue a página depois, o gêmeo mostra imediatamente os últimos valores conhecidos em vez de uma fábrica vazia. Essa única flag é a diferença entre um gêmeo que precisa "aquecer" e um que está correto já na primeira renderização; os prós e contras são discutidos em mensagens MQTT retidas para gêmeos digitais.
Decisões de Design Que Valem a Pena
Configuração em vez de código. Quando a planta ganha um segundo forno, você adiciona uma entrada em bindings.js e uma malha chamada Furnace_2. O runtime não muda, o que também facilita gerar a configuração a partir de um banco de dados de ativos no futuro.
Materiais próprios para cada parte vinculada. Exportadores glTF frequentemente compartilham um único material entre muitas malhas. Sem a etapa de clone(), fazer um forno brilhar faria brilhar todos os objetos que usam esse material.
Status que pode ficar desatualizado. O comportamento status fica cinza se nenhuma mensagem de estado chegar por dez segundos. Um gêmeo nunca deve mostrar "em operação" para uma máquina cujo gateway caiu há uma hora.
Uma única assinatura. Uma única assinatura com curinga sob o prefixo mantém o cliente bem dentro do limite do servidor MQTT público de 10 assinaturas por cliente, e o mapa de roteamento faz o despacho por tópico localmente.
Para um olhar mais aprofundado sobre a camada de mapeamento, incluindo morph targets e clipes de animação, veja como vincular dados IoT a um modelo 3D. Para colocar cartões de KPI e gráficos ao lado da visualização 3D, continue com o tutorial de dashboard de gêmeo digital.
Passo 6: Build e Deploy
O gêmeo é um site estático, então a hospedagem é simples:
npm run build # gera dist/
npx serve dist # verificação local do build de produção
Envie dist/ para qualquer hospedagem estática ou armazenamento de objetos com CDN. Antes disso, passe por esta checklist:
- Aponte
VITE_MQTT_URLpara o seu broker privado e defina um prefixo de tópico de produção. - Nunca inclua credenciais de dispositivos no bundle. O navegador recebe credenciais próprias, e a página fica atrás do seu login.
- Sirva o site via HTTPS; os navegadores bloqueiam conexões
ws://a partir de páginas HTTPS, e é por isso que o gêmeo usawss://na porta 8084. - Comprima o modelo e verifique o tempo de carregamento em uma conexão lenta.
- Teste o que acontece quando o broker está inacessível: o painel deve exibir "MQTT disconnected" e os status devem ficar cinza.
Levando o Gêmeo para o Seu Próprio Broker
O servidor MQTT público é ideal para testes, protótipos e demos, mas tudo nele fica visível para todos, então ele não serve para dados de produção. Para uma planta real, como uma fundição em Joinville, um broker MQTT gerenciado oferece credenciais, TLS e isolamento sem que você precise operar servidores. Com a ConnectMQ, você cria um broker no portal, copia o nome de usuário, a senha e o client ID gerados e adiciona VITE_MQTT_USERNAME, VITE_MQTT_PASSWORD, VITE_MQTT_CLIENT_ID e a URL ao seu ambiente de produção. O broker também detecta hierarquias de tópicos no estilo ISA-95 e pode republicar dados Sparkplug B em tópicos ISA-95 legíveis, o que dá a um gêmeo web tópicos limpos e amigáveis a JSON para vincular, mesmo quando o chão de fábrica fala Sparkplug.
Comece com a ConnectMQ
A maneira mais rápida de ver um resultado pronto é a demo web de gêmeo digital 3D da ConnectMQ, que transmite dados ao vivo para uma cena de fábrica via WebSocket seguro. Quando estiver pronto para rodar o seu próprio gêmeo digital 3D na web, cadastre-se em app.connectmq.com no plano Serverless gratuito, sem cartão de crédito, e aponte VITE_MQTT_URL para o seu novo broker. Ainda escolhendo entre modelos de hospedagem? Nosso guia sobre broker MQTT na nuvem compara as opções.
Perguntas frequentes
Qual formato 3D é o melhor para um gêmeo digital na web?
O glTF binário (.glb) é o padrão para a web: compacto, rápido de carregar e suportado nativamente por Three.js e Babylon.js. Converta formatos CAD como STEP ou FBX para glTF e simplifique-os antes do uso.
Como o gêmeo mostra os valores corretos logo após o carregamento da página?
Os dispositivos publicam seu estado mais recente como mensagens retidas, então o broker entrega o último valor de cada tópico assim que o navegador faz a assinatura. Sem mensagens retidas, o gêmeo fica vazio até a chegada da próxima atualização.
Posso controlar máquinas a partir de um gêmeo digital 3D na web?
Sim, publicando em tópicos de comando a partir da página, por exemplo quando um usuário clica em uma máquina. Trate comandos com cuidado: exija confirmação, use QoS 1 e deixe o dispositivo publicar de volta o seu estado real.
Um gêmeo no navegador funciona em tablets?
Sim. O WebGL roda nos navegadores móveis modernos. Mantenha a contagem de polígonos e o tamanho das texturas moderados e limite o pixel ratio, como faz o exemplo, para manter as taxas de quadros estáveis.



