A Babylon.js digital twin gives you a full-featured 3D engine in the browser with built-in picking, highlight effects and an inspector, which makes interactive twins quick to build. In this tutorial you will create a small cooling skid (a pump, a tank and a pressure gauge) that reacts to live MQTT telemetry over secure WebSocket, glows red on high pressure and lets you start or stop the pump by clicking it. All data flows through the public broker at broker.connectmq.com, and a Python simulator plays the role of the real equipment.
Why Choose Babylon.js for a Digital Twin
Both Babylon.js and Three.js render WebGL scenes in the browser and both work well with MQTT. The difference is in what comes in the box:
| Feature | Babylon.js | Three.js |
|---|---|---|
| Picking and pointer actions | Built in (ActionManager, scene.pick) |
Raycaster, you wire events yourself |
| Glow and highlight effects | HighlightLayer, GlowLayer built in |
Post-processing passes to assemble |
| Debug inspector | scene.debugLayer.show() |
Third-party tools |
| Bundle size and API style | Larger, more batteries included | Smaller, more modular |
| glTF loading | Loaders package | GLTFLoader addon |
If you prefer a lighter library, our Three.js digital twin tutorial builds a similar scene. If the twin is mainly about interaction (click a machine, see details, send a command), Babylon.js saves a lot of plumbing. The broader concepts are covered in our digital twin technology guide.
Topics and Payloads
The twin uses three topics under a unique prefix. Replace bjs-7f3a with your own random suffix:
| Topic | Publisher | Payload | Twin behaviour |
|---|---|---|---|
connectmq/demo/bjs-7f3a/skid1/telemetry |
Equipment | {"pressure":3.2,"level":0.55,"running":true} |
Gauge needle, tank level, pump spin |
connectmq/demo/bjs-7f3a/skid1/cmd |
Twin (on click) | START or STOP |
Simulator changes pump state |
connectmq/demo/bjs-7f3a/skid1/status |
Equipment, retained, last will | online / offline |
Grey-out when offline |
Pressure is in bar, level is a fraction from 0 to 1. Keeping units fixed and documented avoids the most common twin bug: a gauge that shows kilopascals as bar.
Step 1: The Equipment Simulator
Install paho-mqtt 2.x (pip install "paho-mqtt>=2.0") and run this script. It simulates pressure rising slowly while the pump runs, occasionally spiking above the 6 bar alarm limit, and obeys commands from the twin.
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)
Telemetry is published retained, so the twin shows the latest values the moment it connects instead of waiting a second. Check the stream with mosquitto_sub -h broker.connectmq.com -p 1883 -i chk-7f3a -v -t "connectmq/demo/bjs-7f3a/#".
Step 2: Build the Babylon.js Digital Twin
Save this as index.html and serve it with any static server (npx serve .). It loads Babylon.js from the official CDN and MQTT.js as a browser bundle. For production, pin both to exact versions you have tested.
<!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;
};
// Pump: body + 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");
// Tank with liquid that scales on Y (origin moved to the bottom)
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");
// Pressure gauge: dial + needle pivoting at its 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);
// ---------- State ----------
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 { /* ignore malformed payloads */ }
});
// ---------- Click the pump to toggle it ----------
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)));
// ---------- Per-frame update ----------
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 degrees, 10 bar = +120 degrees
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>
Open the page with the simulator running. The tank slowly drains while the pump runs, the needle swings with pressure, the pump glows red whenever pressure exceeds 6 bar, and clicking the pump sends a confirmed command. Stop the simulator with Ctrl+C and, once the broker publishes the last will, the pump turns grey.
How the Babylon.js Bindings Work
Pivot points instead of geometry edits. setPivotPoint moves the scaling and rotation origin to the bottom of the liquid and the base of the needle, so scaling fills from the floor and the needle rotates around its hub. This is the Babylon.js equivalent of setting an origin in Blender.
onBeforeRenderObservable as the single update point. The MQTT handler only writes to target. Everything visual happens once per frame, eased with a frame-rate independent factor. That keeps animation smooth even when messages arrive irregularly.
State changes, not per-frame churn. The highlight layer is only modified when the alarm state flips. Adding and removing meshes every frame would waste work and can flicker.
Commands with confirmation. A click on a 3D object is easy to make by accident, especially while orbiting the camera. The confirm() dialog is the minimum safeguard; real deployments often require a role check too. Commands use QoS 1 so they survive a brief network hiccup; MQTT QoS levels explained shows why.
The device confirms the result. The pump color reflects running from telemetry, not the button you pressed. If the equipment refuses or misses the command, the twin shows the truth.
Performance Tips for Larger Twins
A skid with three meshes runs effortlessly, but a full line with hundreds of parts needs some care. Freeze meshes that never move with mesh.freezeWorldMatrix() so Babylon.js skips their matrix updates. Share materials between static parts and clone only those whose color is bound to data. Use instances (mesh.createInstance) for repeated items such as rollers or pallets. Keep one MQTT connection per page with a wildcard subscription and dispatch messages locally, rather than opening a connection per asset. Finally, throttle text overlays: rewriting DOM elements at 60 frames per second is often more expensive than the 3D rendering itself, so update them a few times per second instead.
Troubleshooting Checklist
- Stuck on "Connecting...": confirm the URL is
wss://broker.connectmq.com:8084/mqtt, including the/mqttpath, and check the browser console for WebSocket errors. Corporate proxies sometimes block WebSocket upgrades. BABYLON is not defined: the Babylon.js script tag failed to load; check the network tab and any content security policy.- Values never appear: your topic prefix in the page differs from the simulator's. Compare them character by character.
- Connection drops repeatedly with two tabs open: each tab must use a unique client ID, which the random suffix handles.
- Use the inspector: call
scene.debugLayer.show()in the console to inspect meshes, materials and pivots live.
For background on WebSocket ports and paths, see MQTT over WebSocket.
From Primitives to a Real Plant Model
Replace the primitives with a glTF model by adding the Babylon.js loaders script and calling BABYLON.SceneLoader.ImportMeshAsync("", "/models/", "skid.glb", scene), then look up parts with scene.getMeshByName("Pump_Body"). The naming conventions and mapping patterns are the same as in any engine; binding IoT data to a 3D model explains them, and building a web based 3D digital twin shows a configuration-driven structure that scales to a whole factory.
Production: Your Own Broker
The public broker is anonymous and visible to anyone, which is fine for this tutorial and wrong for a plant. A hosted MQTT broker gives you credentials, TLS and isolation. With ConnectMQ you create a broker in the portal and receive a generated username, password and client ID; add them to the mqtt.connect options as username, password and clientId (YOUR_USERNAME, YOUR_PASSWORD, YOUR_CLIENT_ID). Secure WebSocket on port 8084 uses a Let's Encrypt certificate, so browsers trust it without configuration. Serve the twin page behind your own login, since anything delivered to the browser can be read by its users.
Get Started with ConnectMQ
ConnectMQ's 3D digital twin web demo shows a full factory scene streaming over secure WebSocket, and the same broker is available for your own Babylon.js digital twin. Create a free broker at app.connectmq.com: the Serverless plan needs no credit card, supports MQTT 5 and up to 25 connections, and includes a live topic explorer for checking your telemetry before you bind it. Until then, the public MQTT test broker is ready for experiments.
Frequently Asked Questions
Is Babylon.js or Three.js better for digital twins?
Both work well. Babylon.js includes more built-in features such as picking actions, highlight layers and an inspector, while Three.js is smaller and more modular; pick the one your team is more comfortable with.
How does a Babylon.js page connect to MQTT?
Through MQTT.js running in the same page over WebSocket, because browsers cannot open raw TCP connections. The broker must offer an MQTT over WebSocket endpoint, ideally secure WebSocket.
Can I click objects in the twin to control equipment?
Yes. Attach an ActionManager to a mesh and publish a command on OnPickTrigger. Add confirmation and let the device report its actual state back.
How do I load my own CAD model into Babylon.js?
Convert it to glTF (.glb), reduce its polygon count, give bound parts unique names and load it with SceneLoader.ImportMeshAsync.



