Digital Twin

Build a Web-Based 3D Digital Twin with glTF and Live MQTT Data

A step-by-step guide to a production-style web based 3D digital twin: model preparation, a Vite project, configuration-driven MQTT bindings, click-to-inspect details, connection health and static deployment.

Web based 3D digital twin of a small factory with furnace, tank, pump and CNC machine shown 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 web based 3D digital twin lets anyone with a browser see the live state of a machine, a line or a whole plant, without installing software. This guide walks through building one properly: preparing a glTF model, setting up a Vite project, binding MQTT topics to parts of the model through a configuration file, adding click-to-inspect details and deploying the result as a static site. The data comes from broker.connectmq.com over secure WebSocket, the same approach used by ConnectMQ's own 3D factory demo.

Architecture of a Web Based 3D Digital Twin

Before writing code, it helps to see the whole system. A browser twin is mostly a front-end application; the broker does the heavy lifting of moving data.

Layer Responsibility Technology in this guide
Devices and gateways Publish telemetry and state Any MQTT client (PLC gateway, ESP32, Python)
Broker Route messages, keep retained state Managed MQTT broker, WSS on port 8084
Model Geometry, named parts, materials glTF binary (.glb)
Binding configuration Which topic field drives which part, and how A JavaScript config module
Runtime Load model, subscribe, animate, interact Three.js and MQTT.js in a Vite app

The key idea is the binding configuration. Instead of writing code for every machine, you write a handful of generic behaviours (heat glow, fill level, rotation, status color) and a list that connects topics to model parts. Adding a new pump then means adding one line of config and one named mesh, not touching the runtime. If you are new to the concept, the digital twin technology overview explains how visual twins relate to simulation and analytics twins.

Step 1: Plan Assets and Topics

A typical factory scene, like the demo mentioned above, has a CNC machine, robot arm, furnace, tank, conveyor and pump. We will use four of those as an example. Write the plan down first; it becomes your config file.

Asset Topic (under your prefix) Field Visual behaviour
Furnace furnace1/telemetry temperature (20 to 900) Emissive glow from dark to orange
Tank tank1/telemetry level (0 to 1) Liquid mesh scales on Y
Pump pump1/telemetry rpm (0 to 3000) Impeller rotates
CNC machine cnc1/state state Beacon color: running, idle, alarm

For the demo we use the prefix connectmq/demo/factory-7f3a; replace the suffix with your own random string. In production you would use a hierarchical namespace such as acme/plant1/area2/line1/furnace1, and our article on MQTT for digital twins explains how to design it.

Step 2: Prepare the 3D Model

Whatever tool your model comes from (Blender, a CAD export, a purchased asset), make it twin-ready before exporting:

  1. Name every bound part uniquely and meaningfully, for example Furnace, Tank_Liquid, Pump_Impeller, CNC_Beacon. Names survive glTF export and are how the runtime finds parts.
  2. Separate moving parts into their own objects with the pivot at the rotation centre. An impeller that is merged into the pump housing cannot spin on its own.
  3. Give the liquid a unit height with its origin at the bottom, so scaling on Y fills from the floor up.
  4. Reduce polygons for anything that is not visually important. CAD exports are often millions of triangles; a browser twin should usually stay well below that.
  5. Export as glTF binary (.glb) with "Apply modifiers" enabled. Optionally compress with Draco or Meshopt for faster loading.

The code below includes a fallback that builds simple boxes with the same names if no model is found, so you can follow along without a model.

Step 3: Scaffold the Project

npm create vite@latest web-twin -- --template vanilla
cd web-twin
npm install three mqtt
mkdir -p public/models   # put factory.glb here

The runtime below awaits the model at the top level of a module, so tell Vite to target modern browsers by creating vite.config.js:

export default { build: { target: "es2022" } };

Create a .env file for settings that change between environments. Vite exposes variables prefixed with VITE_ to the browser:

VITE_MQTT_URL=wss://broker.connectmq.com:8084/mqtt
VITE_TOPIC_PREFIX=connectmq/demo/factory-7f3a

Step 4: Write the Binding Configuration

Create src/bindings.js. Each entry says which topic and field to read, which model part to change and which behaviour to apply.

export const PREFIX = import.meta.env.VITE_TOPIC_PREFIX;

export const bindings = [
  { topic: "furnace1/telemetry", field: "temperature", node: "Furnace",       kind: "heat",   min: 20, max: 900 },
  { topic: "tank1/telemetry",    field: "level",       node: "Tank_Liquid",   kind: "fill",   min: 0,  max: 1 },
  { topic: "pump1/telemetry",    field: "rpm",         node: "Pump_Impeller", kind: "spin",   axis: "z" },
  { topic: "cnc1/state",         field: "state",       node: "CNC_Beacon",    kind: "status" },
];

export const statusColors = {
  RUNNING: 0x2ecc71,
  IDLE: 0xf1c40f,
  ALARM: 0xe74c3c,
  OFFLINE: 0x7f8c8d,
};

Step 5: The Twin Runtime

Replace src/main.js with the runtime below. It loads the model (or builds placeholders), clones materials so parts can change color independently, connects to MQTT, routes messages through the bindings and animates everything in one render loop.

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import mqtt from "mqtt";
import { PREFIX, bindings, statusColors } from "./bindings.js";

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x182030);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 200);
camera.position.set(10, 8, 12);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.style.margin = "0";
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.3));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(8, 15, 6);
scene.add(sun, new THREE.GridHelper(30, 30, 0x4a5568, 0x2d3748));

const panel = Object.assign(document.createElement("pre"), {
  style: "position:absolute;top:10px;left:10px;margin:0;padding:10px;color:#fff;" +
         "background:rgba(0,0,0,.6);border-radius:6px;font:13px monospace;max-width:320px",
  textContent: "Loading model...",
});
document.body.appendChild(panel);

// ----- Model loading with placeholder fallback -----
function buildPlaceholders() {
  const group = new THREE.Group();
  const add = (name, geo, color, pos) => {
    const m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color }));
    m.name = name; m.position.set(...pos); group.add(m); return m;
  };
  add("Furnace", new THREE.BoxGeometry(2, 2, 2), 0x553322, [-4, 1, 0]);
  const liquid = add("Tank_Liquid", new THREE.CylinderGeometry(0.9, 0.9, 1, 32), 0x2f7fd8, [0, 0, 0]);
  liquid.geometry.translate(0, 0.5, 0); // origin at the bottom
  add("Pump_Impeller", new THREE.BoxGeometry(0.2, 1.4, 0.2), 0xcccccc, [3, 1, 0]);
  add("CNC_Beacon", new THREE.SphereGeometry(0.3, 24, 16), 0x7f8c8d, [6, 2.4, 0]);
  add("CNC_Body", new THREE.BoxGeometry(2, 2, 1.5), 0x8899aa, [6, 1, 0]);
  return group;
}

async function loadModel() {
  try {
    const gltf = await new GLTFLoader().loadAsync("/models/factory.glb");
    return gltf.scene;
  } catch {
    console.warn("factory.glb not found, using placeholders");
    return buildPlaceholders();
  }
}

const root = await loadModel();
scene.add(root);

// ----- Resolve bindings to objects and give them private materials -----
const runtime = bindings.map((b) => {
  const obj = root.getObjectByName(b.node);
  if (!obj) console.warn(`Binding target "${b.node}" not found in model`);
  obj?.traverse((o) => { if (o.isMesh) o.material = o.material.clone(); });
  return { ...b, obj, target: b.min ?? 0, current: b.min ?? 0, last: null, at: 0 };
});
const byTopic = new Map();
for (const r of runtime) {
  const full = `${PREFIX}/${r.topic}`;
  if (!byTopic.has(full)) byTopic.set(full, []);
  byTopic.get(full).push(r);
}

// ----- MQTT -----
const client = mqtt.connect(import.meta.env.VITE_MQTT_URL, {
  clientId: import.meta.env.VITE_MQTT_CLIENT_ID || "web-twin-" + crypto.randomUUID().slice(0, 8),
  username: import.meta.env.VITE_MQTT_USERNAME || undefined,
  password: import.meta.env.VITE_MQTT_PASSWORD || undefined,
  protocolVersion: 5,
  reconnectPeriod: 2000,
});
let connected = false;
client.on("connect", () => { connected = true; client.subscribe(`${PREFIX}/#`, { qos: 0 }); });
client.on("close", () => (connected = false));
client.on("message", (topic, payload) => {
  const targets = byTopic.get(topic);
  if (!targets) return;
  let data;
  try { data = JSON.parse(new TextDecoder().decode(payload)); } catch { return; }
  for (const r of targets) {
    if (!(r.field in data)) continue;
    r.target = data[r.field];
    r.last = data;
    r.at = Date.now();
  }
});

// ----- Behaviours -----
const tmp = new THREE.Color();
const behaviours = {
  heat(r, k) {
    r.current += (r.target - r.current) * k;
    const t = THREE.MathUtils.clamp((r.current - r.min) / (r.max - r.min), 0, 1);
    r.obj.traverse((o) => {
      if (!o.isMesh) return;
      o.material.emissive.setRGB(1, 0.35, 0.05);
      o.material.emissiveIntensity = t * 1.5;
    });
  },
  fill(r, k) {
    r.current += (r.target - r.current) * k;
    r.obj.scale.y = Math.max(THREE.MathUtils.clamp(r.current, 0, 1) * 2.8, 0.001);
  },
  spin(r, k, dt) {
    r.current += (r.target - r.current) * k;
    r.obj.rotation[r.axis] += (r.current / 60) * Math.PI * 2 * dt * 0.05;
  },
  status(r) {
    const stale = Date.now() - r.at > 10000;
    tmp.setHex(stale ? statusColors.OFFLINE : statusColors[r.target] ?? statusColors.OFFLINE);
    r.obj.traverse((o) => {
      if (!o.isMesh) return;
      o.material.color.copy(tmp);
      o.material.emissive.copy(tmp);
      o.material.emissiveIntensity = 0.6;
    });
  },
};

// ----- Click to inspect -----
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let selected = null;
renderer.domElement.addEventListener("pointerdown", (e) => {
  pointer.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
  raycaster.setFromCamera(pointer, camera);
  const hit = raycaster.intersectObjects(root.children, true)[0];
  selected = hit ? runtime.find((r) => r.obj && (r.obj === hit.object || r.obj.getObjectById(hit.object.id))) : null;
});

// ----- Render loop -----
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
  const dt = clock.getDelta();
  const k = 1 - Math.exp(-dt * 4);
  for (const r of runtime) if (r.obj) behaviours[r.kind](r, k, dt);

  const head = connected ? "MQTT connected" : "MQTT disconnected";
  panel.textContent = selected
    ? `${head}\n\n${selected.node}\n${JSON.stringify(selected.last, null, 2)}`
    : `${head}\nClick a machine for details`;
  controls.update();
  renderer.render(scene, camera);
});

addEventListener("resize", () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

Run npm run dev and open the local URL. Then feed it data from a terminal:

P=connectmq/demo/factory-7f3a
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/furnace1/telemetry" -m '{"temperature":640}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/tank1/telemetry"    -m '{"level":0.7}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/pump1/telemetry"    -m '{"rpm":1800}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-7f3a -r -t "$P/cnc1/state"        -m '{"state":"RUNNING"}'

The -r flag publishes retained messages, so even if you reload the page later, the twin immediately shows the last known values instead of an empty factory. That one flag is the difference between a twin that "warms up" and one that is correct on first paint; the trade-offs are discussed in retained MQTT messages for digital twins.

Design Decisions That Pay Off

Configuration over code. When the plant gets a second furnace, you add an entry to bindings.js and a mesh named Furnace_2. The runtime does not change, which also makes it easy to generate the config from an asset database later.

Private materials per bound part. glTF exporters often share one material between many meshes. Without the clone() step, making one furnace glow would make every object using that material glow.

Status that can go stale. The status behaviour turns grey if no state message arrives for ten seconds. A twin must never show "running" for a machine whose gateway died an hour ago.

One subscription. A single wildcard subscription under the prefix keeps the client well inside the public broker's limit of 10 subscriptions per client, and the router map does the per-topic dispatch locally.

For a deeper look at the mapping layer, including morph targets and animation clips, see binding IoT data to a 3D model. To put KPI cards and charts next to the 3D view, continue with the digital twin dashboard tutorial.

Step 6: Build and Deploy

The twin is a static site, so hosting is simple:

npm run build      # outputs dist/
npx serve dist     # local check of the production build

Upload dist/ to any static host or object storage with a CDN. Before you do, run through this checklist:

  1. Point VITE_MQTT_URL at your private broker and set a production topic prefix.
  2. Never ship device credentials in the bundle. The browser gets its own credentials, and the page sits behind your login.
  3. Serve the site over HTTPS; browsers block ws:// connections from HTTPS pages, which is why the twin uses wss:// on port 8084.
  4. Compress the model and check the load time on a slow connection.
  5. Test what happens when the broker is unreachable: the panel should say "MQTT disconnected" and statuses should go grey.

Moving the Twin to Your Own Broker

The public broker is ideal for building and demos, but everything on it is visible to everyone. For a real plant, a hosted MQTT broker gives you credentials, TLS and isolation without running servers. With ConnectMQ you create a broker in the portal, copy the generated username, password and client ID, and add VITE_MQTT_USERNAME, VITE_MQTT_PASSWORD, VITE_MQTT_CLIENT_ID and the URL to your production environment. The broker also detects ISA-95 style topic hierarchies and can republish Sparkplug B data into readable ISA-95 topics, which gives a web twin clean JSON-friendly topics to bind to even when the plant floor speaks Sparkplug.

Get Started with ConnectMQ

The quickest way to see a finished result is ConnectMQ's 3D digital twin web demo, which streams live data to a factory scene over secure WebSocket. When you are ready to run your own web based 3D digital twin, sign up at app.connectmq.com for the free Serverless plan, no credit card required, and point VITE_MQTT_URL at your new broker. Choosing between hosting models first? Our guide to the cloud MQTT broker compares the options.

Frequently Asked Questions

Which 3D format is best for a web digital twin?

glTF binary (.glb) is the standard for the web: compact, fast to load and supported natively by Three.js and Babylon.js. Convert CAD formats such as STEP or FBX to glTF and simplify them before use.

How does the twin show correct values right after the page loads?

Devices publish their latest state as retained messages, so the broker delivers the last value of each topic as soon as the browser subscribes. Without retained messages, the twin stays empty until the next update arrives.

Can I control machines from a web based 3D digital twin?

Yes, by publishing to command topics from the page, for example when a user clicks a machine. Treat commands with care: require confirmation, use QoS 1, and let the device publish its actual state back.

Does a browser twin work on tablets?

Yes. WebGL runs on modern mobile browsers. Keep polygon counts and texture sizes modest, and limit the pixel ratio as the example does to keep frame rates smooth.

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.