Digital Twin

Three.js Digital Twin: Live 3D Visualization with MQTT

Build a browser-based Three.js digital twin of a tank and a motor that reacts to live MQTT telemetry over secure WebSocket, with a Node.js simulator, smooth interpolation and production tips.

Three.js digital twin of a tank and motor changing color with live MQTT sensor data in a browser
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 Three.js digital twin turns a stream of sensor messages into a 3D scene you can orbit, zoom and understand at a glance. In this tutorial you will build one in a single HTML file: a tank whose liquid level rises and falls, and a motor that changes color with temperature and spins at its reported speed, all driven by MQTT over secure WebSocket from broker.connectmq.com. You will also write a small simulator so you can test the twin before any real hardware exists.

What a Three.js Digital Twin Needs

A web digital twin has three moving parts, and keeping them separate is what makes it maintainable:

  1. A data feed. Devices publish telemetry to MQTT topics. The browser subscribes with MQTT.js over WebSocket, because browsers cannot open raw TCP sockets.
  2. A state store. Incoming messages update a plain JavaScript object of target values. Nothing in the message handler touches the 3D scene directly.
  3. A render loop. Every animation frame, the scene eases its current values towards the targets and redraws.

This separation matters more than it looks. Messages can arrive in bursts, out of rhythm with the 60 frames per second the browser renders. If you mutate meshes inside the MQTT callback, you get jumpy motion and hard-to-find race conditions. With a state store in between, the renderer always works from a consistent snapshot and you can interpolate smoothly.

For the bigger picture of where this fits, our digital twin technology guide explains the difference between a visual twin like this one and twins that also simulate or predict.

Topic Design for the Twin

Use one topic per asset and a JSON payload per message. Pick a random suffix for the prefix so you do not collide with other people testing on the public broker:

Topic Payload example Drives in the scene
connectmq/demo/twin-7f3a/tank1/telemetry {"level":0.62,"ts":1760600000000} Liquid height (0 to 1)
connectmq/demo/twin-7f3a/motor1/telemetry {"temperature":58.4,"rpm":1320,"ts":...} Body color and fan rotation
connectmq/demo/twin-7f3a/motor1/status online / offline (retained) Grey-out when offline

One topic per asset keeps the browser subscription simple (a single # wildcard under your prefix) and scales cleanly to dozens of assets. Normalised values such as a level between 0 and 1 also keep the scene code free of unit conversions. If you are designing topics for a larger twin, MQTT for digital twins covers hierarchy and payload conventions in more depth.

Step 1: Simulate the Equipment

You do not need hardware to start. This Node.js script publishes realistic-looking data for the tank and the motor once per second. Save it as simulator.mjs, run npm install mqtt, then 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));

The motor "trips" and stops whenever the simulated temperature passes 75 degrees, which gives the twin something interesting to show. Two messages per second is well within the public broker's limit of 10 messages per second per client. Confirm the data is flowing before you touch any 3D code:

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

Step 2: Build the Three.js Digital Twin Scene

Save the following as index.html. It uses an import map to load Three.js and MQTT.js as ES modules from a CDN, so there is no build step. Serve it with any static server, for example npx serve ., and open the printed URL. Opening the file directly from disk also works in most browsers, but a local server avoids module loading restrictions.

<!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");

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

    // Tank: transparent shell + liquid column (geometry height 1, scaled on 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: body + fan that spins with 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);

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

    // ---------- Render loop ----------
    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); // frame-rate independent easing
      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; // slowed for visibility

      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>

With the simulator running, the liquid column rises and falls, the motor shifts from blue to red as it heats up, the fan spins down when the motor trips, and the motor turns grey if you stop the simulator and its last will fires.

How the Live Bindings Work

A few details in that file are worth copying into any Three.js digital twin you build.

Scale a unit-height geometry. The liquid cylinder is created with height 1, so scale.y equals its height in scene units. Moving position.y to half the height keeps the bottom anchored to the tank floor. This is cheaper and simpler than rebuilding geometry on every message.

Mutate materials, do not replace them. Calling material.color.copy(...) updates the existing material. Creating a new material per message leaks GPU resources and causes shader recompiles.

Ease towards targets. The expression 1 - Math.exp(-dt * 4) gives frame-rate independent smoothing: the motion looks the same at 30 or 144 frames per second. With one message per second, raw values would make the scene jump once a second.

Show staleness. A twin that freezes silently is worse than no twin, because people trust it. The HUD flags when no message has arrived for five seconds, and the retained status topic plus the simulator's last will tells you whether the device itself went away. Retained messages also make a freshly opened page show the latest known state immediately; see retained messages for digital twins for when to retain and when not to.

Common Pitfalls

Symptom Likely cause Fix
Page loads, HUD stays on "Connecting..." Wrong URL or missing /mqtt path Use wss://broker.connectmq.com:8084/mqtt
Connects, then disconnects every few seconds Duplicate client ID across tabs Randomise the client ID per page load
Black screen, errors about modules File opened in a way that blocks module imports Serve with npx serve . or another static server
Values update but motion is jerky Meshes updated inside the MQTT callback Write to the state store, animate in the render loop
Mixed content error on an HTTPS site Using ws:// from an https:// page Always use wss:// on port 8084

If messages are not arriving at all, check the browser developer tools Network tab for the WebSocket connection, and read MQTT over WebSocket for a full explanation of ports, paths and proxies.

From Primitives to Real Models

Boxes and cylinders are perfect for proving the data path. The next step is usually a glTF model exported from Blender or a CAD tool, where you look up meshes by name and bind topics to them. That is exactly what binding IoT data to a 3D model covers, including materials, morph targets and animation clips. If you want the full end-to-end structure of a production web twin with configuration-driven bindings, follow building a web-based 3D digital twin. Prefer a different engine? The same MQTT pattern works in Babylon.js.

Going to Production

The public broker is great for this tutorial, but it is anonymous and readable by anyone, so never stream real plant data through it. For production you want your own broker with credentials and TLS. ConnectMQ provides a managed MQTT broker with secure WebSocket on port 8084 and a trusted Let's Encrypt certificate, so the browser connection works without any certificate setup. Add credentials to the MQTT.js options:

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

Anything shipped to a browser is visible to its users, so give the viewer page its own credentials, separate from device credentials so you can rotate them independently, and serve the page only to authenticated users. Many teams put a thin login in front of the page and inject the MQTT credentials after sign-in.

Get Started with ConnectMQ

ConnectMQ ships its own 3D digital twin web demo, a factory with a CNC machine, robot arm, furnace, tank, conveyor and pump updating live over secure WebSocket, built on exactly the pattern in this article. To run your own Three.js digital twin on a private broker, create a free broker at app.connectmq.com. The free Serverless plan needs no credit card and supports up to 25 connections, enough for a few devices and viewers, and the portal's live topic explorer helps you check every binding. Before you go there, you can keep experimenting on the public MQTT test broker.

Frequently Asked Questions

Can Three.js connect to MQTT directly?

Three.js only renders; the connection is handled by MQTT.js in the same page. MQTT.js uses WebSocket in the browser, so the broker must expose an MQTT over WebSocket endpoint, such as port 8084 with the /mqtt path.

How many objects can a browser digital twin update in real time?

Updating transforms and material colors is cheap, so hundreds of bound objects are realistic on ordinary hardware. The usual bottleneck is geometry complexity and draw calls, not MQTT message handling.

Should the twin subscribe with QoS 0 or QoS 1?

QoS 0 is usually right for visual telemetry, because a newer value replaces a lost one within seconds. Use QoS 1 for commands and alarms that must not be missed.

Do I need a backend server for a Three.js digital twin?

Not for live visualization: the browser talks to the broker directly. You need a backend only for things like historical queries, user authentication or issuing per-user credentials.

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.