Digital Twin

Babylon.js Digital Twin with Real-Time IoT Data over MQTT

Build an interactive Babylon.js digital twin of a cooling skid that reads live MQTT telemetry over secure WebSocket, highlights alarms, moves a gauge needle and sends start and stop commands when you click the pump.

Babylon.js digital twin of a cooling skid with a highlighted pump and pressure gauge driven by live MQTT data
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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

  1. Stuck on "Connecting...": confirm the URL is wss://broker.connectmq.com:8084/mqtt, including the /mqtt path, and check the browser console for WebSocket errors. Corporate proxies sometimes block WebSocket upgrades.
  2. BABYLON is not defined: the Babylon.js script tag failed to load; check the network tab and any content security policy.
  3. Values never appear: your topic prefix in the page differs from the simulator's. Compare them character by character.
  4. Connection drops repeatedly with two tabs open: each tab must use a unique client ID, which the random suffix handles.
  5. 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.

Related articles

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.