Um gêmeo digital com Babylon.js oferece um motor 3D completo no navegador, com seleção de objetos (picking), efeitos de destaque e um inspector integrados, o que torna rápido construir gêmeos interativos. Neste tutorial, você vai criar um pequeno skid de resfriamento, como os usados em frigoríficos e plantas de alimentos (uma bomba, um tanque e um manômetro), que reage a telemetria MQTT ao vivo via WebSocket seguro, brilha em vermelho quando a pressão está alta e permite ligar ou desligar a bomba clicando nela. Todos os dados passam pelo servidor MQTT público em broker.connectmq.com, e um simulador em Python faz o papel do equipamento real.
Por Que Escolher o Babylon.js para um Gêmeo Digital
Tanto o Babylon.js quanto o Three.js renderizam cenas WebGL no navegador, e ambos funcionam bem com MQTT. A diferença está no que já vem pronto:
| Recurso | Babylon.js | Three.js |
|---|---|---|
| Seleção e ações de ponteiro | Integrado (ActionManager, scene.pick) |
Raycaster, você mesmo conecta os eventos |
| Efeitos de brilho e destaque | HighlightLayer, GlowLayer integrados |
Passes de pós-processamento para montar |
| Inspector de depuração | scene.debugLayer.show() |
Ferramentas de terceiros |
| Tamanho do bundle e estilo de API | Maior, mais recursos incluídos | Menor, mais modular |
| Carregamento de glTF | Pacote de loaders | Addon GLTFLoader |
Se você prefere uma biblioteca mais leve, nosso tutorial de gêmeo digital com Three.js constrói uma cena parecida. Se o gêmeo gira principalmente em torno de interação (clicar em uma máquina, ver detalhes, enviar um comando), o Babylon.js poupa muito trabalho de integração. Os conceitos mais amplos estão em nosso guia de tecnologia de gêmeo digital.
Tópicos e Payloads
O gêmeo usa três tópicos sob um prefixo único. Substitua bjs-7f3a por um sufixo aleatório seu:
| Tópico | Publicador | Payload | Comportamento do gêmeo |
|---|---|---|---|
connectmq/demo/bjs-7f3a/skid1/telemetry |
Equipamento | {"pressure":3.2,"level":0.55,"running":true} |
Ponteiro do manômetro, nível do tanque, giro da bomba |
connectmq/demo/bjs-7f3a/skid1/cmd |
Gêmeo (ao clicar) | START ou STOP |
O simulador altera o estado da bomba |
connectmq/demo/bjs-7f3a/skid1/status |
Equipamento, retido, last will | online / offline |
Fica cinza quando offline |
A pressão está em bar e o nível é uma fração de 0 a 1. Manter as unidades fixas e documentadas evita o bug mais comum em gêmeos: um manômetro que mostra quilopascals como se fossem bar.
Passo 1: O Simulador do Equipamento
Instale o paho-mqtt 2.x (pip install "paho-mqtt>=2.0") e execute este script. Ele simula a pressão subindo lentamente enquanto a bomba funciona, com picos ocasionais acima do limite de alarme de 6 bar, e obedece aos comandos do gêmeo.
import json
import math
import random
import time
import paho.mqtt.client as mqtt
P = "connectmq/demo/bjs-7f3a/skid1"
state = {"running": True}
def on_connect(client, userdata, flags, reason_code, properties):
print("connected:", reason_code)
client.publish(f"{P}/status", "online", qos=1, retain=True)
client.subscribe(f"{P}/cmd", qos=1)
def on_message(client, userdata, msg):
cmd = msg.payload.decode().strip().upper()
if cmd in ("START", "STOP"):
state["running"] = cmd == "START"
print("command", cmd)
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="bjs-sim-7f3a")
client.will_set(f"{P}/status", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
t, level = 0, 0.5
while True:
t += 1
if state["running"]:
pressure = 4.0 + 1.2 * math.sin(t / 10) + (2.5 if random.random() < 0.05 else 0)
level = max(0.1, level - 0.01)
else:
pressure = max(0.2, 1.0 + random.uniform(-0.1, 0.1))
level = min(0.95, level + 0.02)
payload = {"pressure": round(pressure, 2), "level": round(level, 2),
"running": state["running"]}
client.publish(f"{P}/telemetry", json.dumps(payload), qos=0, retain=True)
time.sleep(1)
A telemetria é publicada como retida, então o gêmeo mostra os valores mais recentes no momento em que se conecta, em vez de esperar um segundo. Confira o fluxo com mosquitto_sub -h broker.connectmq.com -p 1883 -i chk-7f3a -v -t "connectmq/demo/bjs-7f3a/#".
Passo 2: Construa o Gêmeo Digital com Babylon.js
Salve isto como index.html e sirva com qualquer servidor estático (npx serve .). A página carrega o Babylon.js a partir do CDN oficial e o MQTT.js como bundle para navegador. Em produção, fixe ambos em versões exatas que você testou.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Babylon.js Digital Twin</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #111827; }
#c { width: 100%; height: 100%; touch-action: none; }
#info { position: absolute; top: 10px; left: 10px; color: #fff; font: 14px system-ui;
background: rgba(0,0,0,.6); padding: 8px 12px; border-radius: 6px; }
</style>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://unpkg.com/mqtt@5.10.1/dist/mqtt.min.js"></script>
</head>
<body>
<canvas id="c"></canvas>
<div id="info">Connecting...</div>
<script>
const PREFIX = "connectmq/demo/bjs-7f3a/skid1";
const ALARM_BAR = 6.0;
const info = document.getElementById("info");
const canvas = document.getElementById("c");
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0.07, 0.09, 0.15, 1);
const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI / 3, Math.PI / 3, 14, new BABYLON.Vector3(0, 1, 0), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0.3, 1, 0.2), scene);
BABYLON.MeshBuilder.CreateGround("ground", { width: 16, height: 10 }, scene);
const mat = (name, hex) => {
const m = new BABYLON.StandardMaterial(name, scene);
m.diffuseColor = BABYLON.Color3.FromHexString(hex);
return m;
};
// Bomba: corpo + rotor
const pump = BABYLON.MeshBuilder.CreateCylinder("pump", { diameter: 1.6, height: 1.2 }, scene);
pump.rotation.z = Math.PI / 2;
pump.position.set(-3, 0.8, 0);
pump.material = mat("pumpMat", "#3b82f6");
const rotor = BABYLON.MeshBuilder.CreateBox("rotor", { width: 0.1, height: 1.4, depth: 0.2 }, scene);
rotor.position.set(-2.3, 0.8, 0);
rotor.material = mat("rotorMat", "#e5e7eb");
// Tanque com líquido que escala no eixo Y (origem movida para a base)
const shell = BABYLON.MeshBuilder.CreateCylinder("shell", { diameter: 2.2, height: 3 }, scene);
shell.position.set(1, 1.5, 0);
shell.material = mat("shellMat", "#9ca3af");
shell.material.alpha = 0.25;
const liquid = BABYLON.MeshBuilder.CreateCylinder("liquid", { diameter: 2, height: 1 }, scene);
liquid.setPivotPoint(new BABYLON.Vector3(0, -0.5, 0));
liquid.position.set(1, 0.5, 0);
liquid.material = mat("liquidMat", "#0ea5e9");
// Manômetro: mostrador + ponteiro girando a partir da base
const dial = BABYLON.MeshBuilder.CreateDisc("dial", { radius: 0.9, tessellation: 48 }, scene);
dial.position.set(4.5, 2, -0.01);
dial.material = mat("dialMat", "#f9fafb");
const needle = BABYLON.MeshBuilder.CreateBox("needle", { width: 0.06, height: 0.8, depth: 0.02 }, scene);
needle.setPivotPoint(new BABYLON.Vector3(0, -0.4, 0));
needle.position.set(4.5, 2.4, -0.05);
needle.material = mat("needleMat", "#dc2626");
const highlight = new BABYLON.HighlightLayer("hl", scene);
// ---------- Estado ----------
const target = { pressure: 0, level: 0, running: false, online: false };
const cur = { pressure: 0, level: 0, rotorSpeed: 0 };
let alarmOn = false;
// ---------- MQTT ----------
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "bjs-twin-" + Math.random().toString(16).slice(2, 10),
protocolVersion: 5,
reconnectPeriod: 2000,
});
const dec = new TextDecoder();
client.on("connect", () => client.subscribe(`${PREFIX}/#`, { qos: 1 }));
client.on("message", (topic, payload) => {
const text = dec.decode(payload);
if (topic === `${PREFIX}/status`) { target.online = text === "online"; return; }
if (topic !== `${PREFIX}/telemetry`) return;
try {
const d = JSON.parse(text);
if (typeof d.pressure === "number") target.pressure = d.pressure;
if (typeof d.level === "number") target.level = Math.min(Math.max(d.level, 0), 1);
if (typeof d.running === "boolean") target.running = d.running;
} catch { /* ignora payloads malformados */ }
});
// ---------- Clique na bomba para alternar o estado ----------
pump.actionManager = new BABYLON.ActionManager(scene);
pump.actionManager.registerAction(new BABYLON.ExecuteCodeAction(
BABYLON.ActionManager.OnPickTrigger, () => {
const cmd = target.running ? "STOP" : "START";
if (confirm(`Send ${cmd} to pump?`)) client.publish(`${PREFIX}/cmd`, cmd, { qos: 1 });
}));
pump.actionManager.registerAction(new BABYLON.SetValueAction(
BABYLON.ActionManager.OnPointerOverTrigger, pump, "scaling", new BABYLON.Vector3(1.05, 1.05, 1.05)));
pump.actionManager.registerAction(new BABYLON.SetValueAction(
BABYLON.ActionManager.OnPointerOutTrigger, pump, "scaling", new BABYLON.Vector3(1, 1, 1)));
// ---------- Atualização por quadro ----------
scene.onBeforeRenderObservable.add(() => {
const dt = engine.getDeltaTime() / 1000;
const k = 1 - Math.exp(-dt * 4);
cur.pressure += (target.pressure - cur.pressure) * k;
cur.level += (target.level - cur.level) * k;
cur.rotorSpeed += ((target.running ? 12 : 0) - cur.rotorSpeed) * k;
liquid.scaling.y = Math.max(cur.level * 2.9, 0.01);
rotor.rotation.x += cur.rotorSpeed * dt;
// 0 bar = -120 graus, 10 bar = +120 graus
needle.rotation.z = -BABYLON.Tools.ToRadians(-120 + (Math.min(cur.pressure, 10) / 10) * 240);
pump.material.diffuseColor = BABYLON.Color3.FromHexString(
!target.online ? "#6b7280" : target.running ? "#22c55e" : "#3b82f6");
const shouldAlarm = target.online && target.pressure > ALARM_BAR;
if (shouldAlarm !== alarmOn) {
alarmOn = shouldAlarm;
if (alarmOn) highlight.addMesh(pump, BABYLON.Color3.Red());
else highlight.removeMesh(pump);
}
info.textContent = `${client.connected ? "Live" : "Disconnected"} | ` +
`${cur.pressure.toFixed(2)} bar | level ${(cur.level * 100).toFixed(0)} % | ` +
`pump ${target.running ? "running" : "stopped"}${alarmOn ? " | HIGH PRESSURE" : ""}`;
});
engine.runRenderLoop(() => scene.render());
addEventListener("resize", () => engine.resize());
</script>
</body>
</html>
Abra a página com o simulador em execução. O tanque esvazia lentamente enquanto a bomba funciona, o ponteiro oscila com a pressão, a bomba brilha em vermelho sempre que a pressão passa de 6 bar e clicar na bomba envia um comando confirmado. Pare o simulador com Ctrl+C e, assim que o broker publicar o last will, a bomba fica cinza.
Como Funcionam os Vínculos no Babylon.js
Pontos de pivô em vez de editar a geometria. setPivotPoint move a origem de escala e rotação para a base do líquido e para a base do ponteiro, de modo que a escala enche a partir do fundo e o ponteiro gira em torno do seu eixo. É o equivalente, no Babylon.js, a definir uma origem no Blender.
onBeforeRenderObservable como ponto único de atualização. O handler MQTT apenas escreve em target. Tudo o que é visual acontece uma vez por quadro, suavizado com um fator independente da taxa de quadros. Isso mantém a animação fluida mesmo quando as mensagens chegam de forma irregular.
Mudanças de estado, não retrabalho a cada quadro. A camada de destaque só é modificada quando o estado do alarme muda. Adicionar e remover malhas a cada quadro desperdiçaria processamento e pode causar cintilação.
Comandos com confirmação. Um clique em um objeto 3D é fácil de acontecer por acidente, especialmente ao orbitar a câmera. A caixa de diálogo confirm() é a proteção mínima; implantações reais costumam exigir também uma verificação de perfil. Os comandos usam QoS 1 para sobreviver a uma breve instabilidade de rede; níveis de QoS do MQTT explicados mostra por quê.
O dispositivo confirma o resultado. A cor da bomba reflete o running da telemetria, não o botão que você pressionou. Se o equipamento recusar ou perder o comando, o gêmeo mostra a verdade.
Dicas de Desempenho para Gêmeos Maiores
Um skid com três malhas roda sem esforço, mas uma linha completa com centenas de peças exige alguns cuidados. Congele as malhas que nunca se movem com mesh.freezeWorldMatrix() para que o Babylon.js pule a atualização de suas matrizes. Compartilhe materiais entre peças estáticas e clone apenas aqueles cuja cor está vinculada a dados. Use instâncias (mesh.createInstance) para itens repetidos, como roletes ou paletes. Mantenha uma única conexão MQTT por página, com uma assinatura com curinga, e despache as mensagens localmente, em vez de abrir uma conexão por ativo. Por fim, limite a frequência das sobreposições de texto: reescrever elementos do DOM a 60 quadros por segundo costuma ser mais caro do que a própria renderização 3D, então atualize-os algumas vezes por segundo.
Checklist de Solução de Problemas
- Travado em "Connecting...": confirme que a URL é
wss://broker.connectmq.com:8084/mqtt, incluindo o caminho/mqtt, e verifique no console do navegador se há erros de WebSocket. Proxies corporativos às vezes bloqueiam o upgrade para WebSocket. BABYLON is not defined: a tag de script do Babylon.js não carregou; verifique a aba de rede e qualquer content security policy.- Os valores nunca aparecem: o prefixo de tópico da página é diferente do prefixo do simulador. Compare-os caractere por caractere.
- A conexão cai repetidamente com duas abas abertas: cada aba precisa usar um client ID único, o que o sufixo aleatório garante.
- Use o inspector: chame
scene.debugLayer.show()no console para inspecionar malhas, materiais e pivôs ao vivo.
Para entender portas e caminhos de WebSocket, veja MQTT sobre WebSocket.
De Primitivas a um Modelo Real da Planta
Substitua as primitivas por um modelo glTF adicionando o script de loaders do Babylon.js e chamando BABYLON.SceneLoader.ImportMeshAsync("", "/models/", "skid.glb", scene); depois, localize as peças com scene.getMeshByName("Pump_Body"). As convenções de nomenclatura e os padrões de mapeamento são os mesmos em qualquer motor; como vincular dados IoT a um modelo 3D os explica, e como construir um gêmeo digital 3D na web mostra uma estrutura orientada por configuração que escala para uma fábrica inteira.
Produção: Seu Próprio Broker
O servidor MQTT público é anônimo e visível para qualquer pessoa, o que serve para este tutorial e para protótipos, mas é inadequado para dados de produção de uma planta. Um broker MQTT gerenciado oferece credenciais, TLS e isolamento. Com a ConnectMQ, você cria um broker no portal e recebe um nome de usuário, uma senha e um client ID gerados; adicione-os às opções de mqtt.connect como username, password e clientId (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID). O WebSocket seguro na porta 8084 usa um certificado Let's Encrypt, então os navegadores confiam nele sem configuração. Sirva a página do gêmeo atrás do seu próprio login, já que tudo o que é entregue ao navegador pode ser lido pelos seus usuários.
Comece com a ConnectMQ
A demo web de gêmeo digital 3D da ConnectMQ mostra uma cena de fábrica completa transmitida via WebSocket seguro, e o mesmo broker está disponível para o seu próprio gêmeo digital com Babylon.js. Crie um broker gratuito em app.connectmq.com: o plano Serverless não exige cartão de crédito, suporta MQTT 5 e até 25 conexões e inclui um explorador de tópicos ao vivo para conferir sua telemetria antes de vinculá-la. Até lá, o servidor MQTT público (gratuito) está pronto para experimentos.
Perguntas frequentes
Babylon.js ou Three.js: qual é melhor para gêmeos digitais?
Ambos funcionam bem. O Babylon.js inclui mais recursos integrados, como ações de seleção, camadas de destaque e um inspector, enquanto o Three.js é menor e mais modular; escolha aquele com que sua equipe se sente mais à vontade.
Como uma página Babylon.js se conecta ao MQTT?
Por meio do MQTT.js rodando na mesma página via WebSocket, porque os navegadores não conseguem abrir conexões TCP puras. O broker precisa oferecer um endpoint de MQTT sobre WebSocket, de preferência WebSocket seguro.
Posso clicar em objetos do gêmeo para controlar equipamentos?
Sim. Anexe um ActionManager a uma malha e publique um comando em OnPickTrigger. Adicione uma confirmação e deixe o dispositivo informar de volta o seu estado real.
Como carrego meu próprio modelo CAD no Babylon.js?
Converta-o para glTF (.glb), reduza a contagem de polígonos, dê nomes únicos às partes vinculadas e carregue-o com SceneLoader.ImportMeshAsync.



