Gêmeo digital

Gêmeo Digital com Three.js: Visualização 3D ao Vivo com MQTT

Construa no navegador um gêmeo digital com Three.js de um tanque e de um motor que reage à telemetria MQTT ao vivo via WebSocket seguro, com um simulador em Node.js, interpolação suave e dicas para produção.

Gêmeo digital com Three.js de um tanque e um motor mudando de cor com dados de sensores MQTT ao vivo em um navegador
Rode isso no seu próprio broker MQTTTenha um broker MQTT privado e gerenciado no plano Free e comece com exemplos de código prontos.
Teste grátis Exemplos de código Sem cartão de crédito

Um gêmeo digital com Three.js transforma um fluxo de mensagens de sensores em uma cena 3D que você pode orbitar, ampliar e entender com um olhar. Neste tutorial você vai construir um em um único arquivo HTML: um tanque cujo nível de líquido sobe e desce e um motor que muda de cor conforme a temperatura e gira na velocidade informada, tudo alimentado por MQTT sobre WebSocket seguro a partir do servidor MQTT público em broker.connectmq.com. Você também vai escrever um pequeno simulador para testar o gêmeo antes que exista qualquer hardware real.

Do Que um Gêmeo Digital com Three.js Precisa

Um gêmeo digital web tem três partes móveis, e mantê-las separadas é o que o torna sustentável:

  1. Um fluxo de dados. Os dispositivos publicam telemetria em topics MQTT. O navegador assina com o MQTT.js via WebSocket, porque navegadores não conseguem abrir sockets TCP puros.
  2. Um armazenamento de estado. As mensagens recebidas atualizam um objeto JavaScript simples com valores-alvo. Nada no handler de mensagens mexe diretamente na cena 3D.
  3. Um loop de renderização. A cada frame de animação, a cena aproxima suavemente seus valores atuais dos valores-alvo e redesenha.

Essa separação importa mais do que parece. As mensagens podem chegar em rajadas, fora do ritmo dos 60 frames por segundo que o navegador renderiza. Se você alterar as malhas dentro do callback MQTT, terá movimentos bruscos e race conditions difíceis de encontrar. Com um armazenamento de estado no meio, o renderizador sempre trabalha a partir de um snapshot consistente e você pode interpolar suavemente.

Para a visão geral de onde isso se encaixa, nosso guia de tecnologia de gêmeo digital explica a diferença entre um gêmeo visual como este e gêmeos que também simulam ou fazem previsões.

Projeto de Topics para o Gêmeo

Use um topic por ativo e um payload JSON por mensagem. Escolha um sufixo aleatório para o prefixo, para não colidir com outras pessoas testando no servidor MQTT público:

Topic Exemplo de payload O que aciona na cena
connectmq/demo/twin-7f3a/tank1/telemetry {"level":0.62,"ts":1760600000000} Altura do líquido (0 a 1)
connectmq/demo/twin-7f3a/motor1/telemetry {"temperature":58.4,"rpm":1320,"ts":...} Cor do corpo e rotação da ventoinha
connectmq/demo/twin-7f3a/motor1/status online / offline (retido) Fica cinza quando offline

Um topic por ativo mantém a assinatura do navegador simples (um único wildcard # sob o seu prefixo) e escala bem para dezenas de ativos. Valores normalizados, como um nível entre 0 e 1, também mantêm o código da cena livre de conversões de unidades. Se você estiver projetando topics para um gêmeo maior, MQTT para gêmeos digitais aborda hierarquia e convenções de payload com mais profundidade.

Passo 1: Simule os Equipamentos

Você não precisa de hardware para começar. Este script Node.js publica dados com aparência realista para o tanque e o motor uma vez por segundo. Salve-o como simulator.mjs, rode npm install mqtt e depois 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));

O motor "desarma" e para sempre que a temperatura simulada passa de 75 graus, o que dá ao gêmeo algo interessante para mostrar. Duas mensagens por segundo ficam bem dentro do limite do servidor MQTT público, de 10 mensagens por segundo por cliente. Confirme que os dados estão fluindo antes de mexer em qualquer código 3D:

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

Passo 2: Construa a Cena do Gêmeo Digital com Three.js

Salve o código a seguir como index.html. Ele usa um import map para carregar o Three.js e o MQTT.js como módulos ES a partir de uma CDN, então não há etapa de build. Sirva-o com qualquer servidor estático, por exemplo npx serve ., e abra a URL exibida. Abrir o arquivo diretamente do disco também funciona na maioria dos navegadores, mas um servidor local evita restrições no carregamento 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");

    // ---------- Cena ----------
    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));

    // Tanque: casco transparente + coluna de líquido (geometria de altura 1, escalada em 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: corpo + ventoinha que gira conforme o 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);

    // ---------- Armazenamento 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;
      }
    });

    // ---------- Loop de renderização ----------
    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); // suavização independente da taxa de quadros
      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; // desacelerado para ficar visível

      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>

Com o simulador rodando, a coluna de líquido sobe e desce, o motor passa de azul para vermelho conforme esquenta, a ventoinha desacelera quando o motor desarma, e o motor fica cinza se você parar o simulador e o last will dele for disparado.

Como Funcionam as Vinculações ao Vivo

Alguns detalhes desse arquivo valem a pena ser copiados para qualquer gêmeo digital com Three.js que você construir.

Escale uma geometria de altura unitária. O cilindro do líquido é criado com altura 1, então scale.y é igual à sua altura em unidades da cena. Mover position.y para metade da altura mantém a base ancorada no fundo do tanque. Isso é mais barato e mais simples do que reconstruir a geometria a cada mensagem.

Altere os materiais, não os substitua. Chamar material.color.copy(...) atualiza o material existente. Criar um material novo a cada mensagem vaza recursos da GPU e provoca recompilações de shaders.

Aproxime-se suavemente dos valores-alvo. A expressão 1 - Math.exp(-dt * 4) produz uma suavização independente da taxa de quadros: o movimento parece o mesmo a 30 ou a 144 frames por segundo. Com uma mensagem por segundo, os valores brutos fariam a cena saltar uma vez por segundo.

Mostre quando os dados estão desatualizados. Um gêmeo que congela em silêncio é pior do que nenhum gêmeo, porque as pessoas confiam nele. O HUD sinaliza quando nenhuma mensagem chega por cinco segundos, e o topic status retido mais o last will do simulador informam se o próprio dispositivo desapareceu. As retained messages também fazem uma página recém-aberta mostrar imediatamente o último estado conhecido; veja retained messages para gêmeos digitais para saber quando reter e quando não reter.

Armadilhas Comuns

Sintoma Causa provável Solução
A página carrega e o HUD fica em "Connecting..." URL errada ou caminho /mqtt ausente Use wss://broker.connectmq.com:8084/mqtt
Conecta e depois desconecta a cada poucos segundos Client ID duplicado entre abas Gere um client ID aleatório a cada carregamento da página
Tela preta com erros sobre módulos Arquivo aberto de um jeito que bloqueia imports de módulos Sirva com npx serve . ou outro servidor estático
Os valores atualizam, mas o movimento é brusco Malhas atualizadas dentro do callback MQTT Grave no armazenamento de estado e anime no loop de renderização
Erro de mixed content em um site HTTPS Uso de ws:// a partir de uma página https:// Sempre use wss:// na porta 8084

Se as mensagens não estiverem chegando de forma alguma, verifique a conexão WebSocket na aba Network das ferramentas de desenvolvedor do navegador e leia MQTT sobre WebSocket para uma explicação completa sobre portas, caminhos e proxies.

De Primitivas a Modelos Reais

Caixas e cilindros são perfeitos para comprovar o caminho dos dados. O próximo passo costuma ser um modelo glTF exportado do Blender ou de uma ferramenta CAD, no qual você localiza as malhas pelo nome e vincula topics a elas. É exatamente isso que vinculando dados de IoT a um modelo 3D aborda, incluindo materiais, morph targets e clipes de animação. Se você quer a estrutura completa de ponta a ponta de um gêmeo web de produção com vinculações orientadas por configuração, siga construindo um gêmeo digital 3D baseado na web. Prefere outro engine? O mesmo padrão MQTT funciona no Babylon.js.

Indo para Produção

O servidor MQTT público é ótimo para este tutorial e para protótipos, mas é anônimo e pode ser lido por qualquer pessoa, então nunca transmita por ele dados reais de fábrica, seja de uma planta em Manaus ou de uma estação de bombeamento de saneamento. Em produção, você quer o seu próprio broker com credenciais e TLS. A ConnectMQ oferece um broker MQTT gerenciado com WebSocket seguro na porta 8084 e um certificado Let's Encrypt confiável, então a conexão do navegador funciona sem nenhuma configuração de certificado. Adicione as credenciais às opções do MQTT.js:

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

Tudo o que é enviado a um navegador fica visível para os seus usuários, então dê à página de visualização suas próprias credenciais, separadas das credenciais dos dispositivos, para poder rotacioná-las de forma independente, e sirva a página apenas a usuários autenticados. Muitas equipes colocam um login simples na frente da página e injetam as credenciais MQTT após o acesso.

Comece com a ConnectMQ

A ConnectMQ oferece sua própria demo web de gêmeo digital 3D, uma fábrica com máquina CNC, braço robótico, forno, tanque, esteira e bomba atualizando ao vivo via WebSocket seguro, construída exatamente sobre o padrão deste artigo. Para rodar o seu próprio gêmeo digital com Three.js em um broker privado, crie um broker gratuito em app.connectmq.com. O plano Serverless (Free) não exige cartão de crédito e suporta até 25 conexões, o suficiente para alguns dispositivos e visualizadores, e o explorador de topics em tempo real do portal ajuda você a conferir cada vinculação. Antes disso, você pode continuar experimentando no servidor MQTT público (gratuito).

Perguntas frequentes

O Three.js consegue se conectar ao MQTT diretamente?

O Three.js apenas renderiza; a conexão é feita pelo MQTT.js na mesma página. O MQTT.js usa WebSocket no navegador, então o broker precisa expor um endpoint de MQTT sobre WebSocket, como a porta 8084 com o caminho /mqtt.

Quantos objetos um gêmeo digital no navegador consegue atualizar em tempo real?

Atualizar transformações e cores de materiais é barato, então centenas de objetos vinculados são realistas em hardware comum. O gargalo habitual é a complexidade da geometria e as draw calls, não o tratamento das mensagens MQTT.

O gêmeo deve assinar com QoS 0 ou QoS 1?

QoS 0 costuma ser o certo para telemetria visual, porque um valor mais novo substitui um perdido em segundos. Use QoS 1 para comandos e alarmes que não podem ser perdidos.

Preciso de um servidor de backend para um gêmeo digital com Three.js?

Não para a visualização ao vivo: o navegador conversa diretamente com o broker. Você só precisa de um backend para coisas como consultas históricas, autenticação de usuários ou emissão de credenciais por usuário.

Artigos relacionados

Todos os artigos →

Seu broker MQTT privado está a poucos cliques de distância

Crie uma conta, receba as credenciais do seu broker e conecte seu primeiro dispositivo — sem servidores para instalar, atualizar ou escalar.