To bind IoT data to a 3D model, you need a small, well-defined layer that takes a message from a topic, picks a value out of it, transforms it and applies it to a specific part of the model. This guide builds that layer for glTF models in Three.js: you will map MQTT values to transforms, visibility, material colors, morph targets and animation clips, and store the binding definitions inside the model itself using glTF extras. Data streams from broker.connectmq.com, so you can test every binding from a terminal.
The Anatomy of a Binding
Every binding, regardless of engine, answers five questions:
| Question | Example | Field in our binding |
|---|---|---|
| Which topic? | connectmq/demo/bind-7f3a/press1/telemetry |
topic (supports + and #) |
| Which value in the payload? | hydraulics.pressure |
path |
| How is it transformed? | 0 to 250 bar becomes 0 to 1 | map: [inMin, inMax, outMin, outMax], clamp |
| Which part of the model? | Node named Press_Ram |
node |
| What property changes? | Position on Y, emissive color, animation speed | kind and its options |
Writing these down as data rather than code is the most important decision in a twin. Code holds a few generic "kinds"; data says which topic drives which part. When an engineer adds a sensor, nobody edits JavaScript. If you have not built a twin before, start with the Three.js digital twin tutorial, then come back here for the mapping layer, and see the digital twin technology guide for the wider context.
Prepare the glTF Model for Data Binding
A model exported for marketing renders is rarely ready to be bound to data. Fix these things in Blender (or your DCC tool) before export:
- Unique, stable node names.
Press_Ram,Press_Beacon,Tank_Liquid. Avoid names likeCylinder.014that change on re-export. - Correct pivots. A rotating arm needs its origin at the joint; a fill level needs its origin at the bottom.
- Separate objects for bound parts. If the beacon is joined to the machine body, changing its color colors the whole machine.
- Morph targets (shape keys) for continuous deformations such as a hose stretching or a gauge face, and animation clips (actions) for cyclic motion such as a conveyor or a fan.
- Custom properties for bindings. Blender exports object custom properties as glTF
extraswhen "Include > Custom Properties" is enabled, and Three.js places them inobject.userData.
That last point lets the 3D artist and the automation engineer work in one file. Add a custom property called binding to Press_Ram with a JSON string value:
{
"topic": "connectmq/demo/bind-7f3a/press1/telemetry",
"path": "ram.position",
"map": [0, 400, 0, -1.2],
"clamp": true,
"kind": "translate",
"axis": "y"
}
After export and loading, object.userData.binding contains that string.
Binding Kinds That Cover Most Twins
You need surprisingly few kinds. These six cover the bulk of industrial visualisations:
| Kind | Applies to | Typical data | Implementation |
|---|---|---|---|
translate |
Node position on an axis | Cylinder stroke, lift height | Offset from rest position |
rotate |
Node rotation on an axis | Valve opening, arm angle | Angle from rest rotation |
spin |
Continuous rotation | Motor rpm, fan speed | Add value * dt each frame |
color |
Material color or emissive | Temperature, status enum | Gradient or lookup table |
morph |
Morph target influence | Fill level, deformation | morphTargetInfluences[i] |
clip |
Animation clip speed or weight | Conveyor speed, cycle running | AnimationAction.timeScale |
Visibility is a special case of color-like lookups (visible: true/false from a boolean), and text labels are usually better as HTML overlays than as 3D text.
The Binding Engine
The module below loads a glTF, collects bindings from userData, matches incoming MQTT topics (including wildcards), extracts and transforms values, and applies them every frame. Save it as binding-engine.js; it assumes Three.js and MQTT.js are available through an import map or a bundler, as in the previous tutorials.
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
// ---------- helpers ----------
export function topicMatches(filter, topic) {
const f = filter.split("/"), t = topic.split("/");
for (let i = 0; i < f.length; i++) {
if (f[i] === "#") return true;
if (i >= t.length) return false;
if (f[i] !== "+" && f[i] !== t[i]) return false;
}
return f.length === t.length;
}
const getPath = (obj, path) =>
path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
function transform(value, b) {
if (b.lookup) return b.lookup[value] ?? b.lookup.default;
let v = Number(value);
if (Number.isNaN(v)) return undefined;
if (b.map) {
const [a, c, d, e] = b.map;
v = d + ((v - a) / (c - a)) * (e - d);
if (b.clamp) v = THREE.MathUtils.clamp(v, Math.min(d, e), Math.max(d, e));
}
return v;
}
// ---------- engine ----------
export async function createTwin(scene, url) {
const gltf = await new GLTFLoader().loadAsync(url);
scene.add(gltf.scene);
const mixer = new THREE.AnimationMixer(gltf.scene);
const bindings = [];
gltf.scene.traverse((obj) => {
if (!obj.userData.binding) return;
let b;
try { b = JSON.parse(obj.userData.binding); } catch { console.warn("Bad binding on", obj.name); return; }
b.obj = obj;
b.rest = { position: obj.position.clone(), rotation: obj.rotation.clone() };
b.target = b.current = undefined;
if (b.kind === "color") {
obj.traverse((o) => { if (o.isMesh) o.material = o.material.clone(); });
}
if (b.kind === "clip") {
const clip = THREE.AnimationClip.findByName(gltf.animations, b.clip);
if (clip) { b.action = mixer.clipAction(clip); b.action.play(); b.action.timeScale = 0; }
else console.warn(`Clip ${b.clip} not found`);
}
bindings.push(b);
});
function onMessage(topic, payload) {
let data;
const text = new TextDecoder().decode(payload);
try { data = JSON.parse(text); } catch { data = text; }
for (const b of bindings) {
if (!topicMatches(b.topic, topic)) continue;
const raw = b.path ? getPath(data, b.path) : data;
const v = transform(raw, b);
if (v !== undefined) b.target = v;
}
}
const color = new THREE.Color();
function update(dt) {
const k = 1 - Math.exp(-dt * 5);
for (const b of bindings) {
if (b.target === undefined) continue;
if (typeof b.target === "number") {
b.current = b.current === undefined ? b.target : b.current + (b.target - b.current) * k;
} else {
b.current = b.target; // strings, colors, booleans: no easing
}
const o = b.obj, v = b.current;
switch (b.kind) {
case "translate": o.position[b.axis] = b.rest.position[b.axis] + v; break;
case "rotate": o.rotation[b.axis] = b.rest.rotation[b.axis] + THREE.MathUtils.degToRad(v); break;
case "spin": o.rotation[b.axis] += v * dt; break;
case "visible": o.visible = Boolean(v); break;
case "color":
color.set(typeof v === "number"
? new THREE.Color(b.from).lerp(new THREE.Color(b.to), v)
: v);
o.traverse((m) => {
if (!m.isMesh) return;
m.material[b.channel ?? "color"].copy(color);
});
break;
case "morph":
o.traverse((m) => {
if (m.morphTargetInfluences && m.morphTargetDictionary?.[b.morph] !== undefined) {
m.morphTargetInfluences[m.morphTargetDictionary[b.morph]] = v;
}
});
break;
case "clip": if (b.action) b.action.timeScale = v; break;
}
}
mixer.update(dt);
}
const filters = [...new Set(bindings.map((b) => b.topic))];
return { bindings, filters, onMessage, update };
}
Wire it into a page:
import * as THREE from "three";
import mqtt from "mqtt";
import { createTwin } from "./binding-engine.js";
// scene, camera and renderer set up as usual ...
const twin = await createTwin(scene, "/models/press.glb");
const client = mqtt.connect("wss://broker.connectmq.com:8084/mqtt", {
clientId: "bind-viewer-" + crypto.randomUUID().slice(0, 8),
protocolVersion: 5,
});
client.on("connect", () => client.subscribe(twin.filters, { qos: 0 }));
client.on("message", twin.onMessage);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
twin.update(clock.getDelta());
renderer.render(scene, camera);
});
Subscribing to exactly the filters the model declares, rather than #, keeps traffic low. On the public broker, mind the limit of 10 subscriptions per client; if a model declares more filters than that, subscribe to a common parent with a wildcard instead.
More Binding Examples
The same engine handles very different parts with nothing but data. A status beacon driven by an enum, using a lookup table and the emissive channel:
{
"topic": "connectmq/demo/bind-7f3a/press1/state",
"path": "state",
"lookup": { "RUNNING": "#22c55e", "IDLE": "#eab308", "FAULT": "#ef4444", "default": "#6b7280" },
"kind": "color",
"channel": "emissive"
}
A tank whose level is a morph target called Fill, fed by a percentage:
{ "topic": "connectmq/demo/bind-7f3a/tank1/telemetry", "path": "level_pct",
"map": [0, 100, 0, 1], "clamp": true, "kind": "morph", "morph": "Fill" }
A conveyor belt animation whose speed follows the drive frequency (50 Hz plays the clip at normal speed):
{ "topic": "connectmq/demo/bind-7f3a/conv1/telemetry", "path": "drive.hz",
"map": [0, 50, 0, 1], "clamp": true, "kind": "clip", "clip": "BeltLoop" }
Test any binding straight from a terminal:
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-bind-7f3a -r \
-t "connectmq/demo/bind-7f3a/press1/telemetry" -m '{"ram":{"position":220}}'
mosquitto_pub -h broker.connectmq.com -p 1883 -i feed-bind-7f3a -r \
-t "connectmq/demo/bind-7f3a/press1/state" -m '{"state":"FAULT"}'
The -r flag retains the message, so the model shows the value as soon as it loads. Retained state is the backbone of a twin that is correct on first paint; retained MQTT messages for digital twins discusses when to use it.
Pitfalls When You Bind IoT Data to a 3D Model
- Shared materials. glTF exporters reuse materials. Clone them for any part whose color is bound, or one alarm turns the whole factory red.
- Absolute versus relative transforms. Always apply values relative to the rest pose captured at load time. Otherwise the part jumps to the world origin.
- Degrees versus radians. Sensors report degrees; Three.js wants radians. Convert in one place, as
rotatedoes. - Missing values. Payloads evolve. If
pathresolves toundefined, keep the previous value rather than snapping to zero. - Stale data. Record when each binding last updated and dim parts that have not heard from their device for a while.
- Node renamed in the model. Log a warning for every binding whose node or clip cannot be found at load time; silent failures are the hardest to debug.
- Too much easing. Smoothing suits continuous values but hides discrete events. Do not ease enums, booleans or alarm states.
Latency budgets for each of these stages are covered in real-time digital twin latency.
Where the Data Comes From
Bindings are only as good as the topics behind them. A consistent hierarchy such as site/area/line/cell/asset/metric lets you bind with wildcards and reuse models across lines. If your plant publishes Sparkplug B, ConnectMQ can decode it and republish it into readable ISA-95 style topics, such as connectmq/factory1/production/line1/cnc1/telemetry, which are far easier to bind than Protobuf payloads. The Sparkplug B and Unified Namespace guide explains that structure. For a complete application that uses this engine with configuration and deployment, see building a web based 3D digital twin, or follow the conveyor digital twin tutorial for a focused example.
Get Started with ConnectMQ
Once your bindings work on the public broker, move them to a private one. A managed MQTT broker from ConnectMQ provides secure WebSocket on port 8084 with a trusted certificate, plus a live topic explorer that shows exactly which paths exist in your payloads, which is invaluable when writing bindings. Create a free broker at app.connectmq.com; the Serverless plan needs no credit card. Keep using the free public MQTT broker for quick experiments with non-sensitive data.
Frequently Asked Questions
Where should binding definitions live: in the model or in code?
Either works. Storing them as glTF extras keeps model and data mapping together for small teams, while a separate JSON config is easier to generate from an asset database and to review in version control.
Can I bind data to animations exported from Blender?
Yes. Exported actions become glTF animation clips. Play them through an AnimationMixer and bind the action's timeScale for speed or its weight for blending between states.
How do I bind data to a part that has no separate mesh?
You cannot move or recolor part of a merged mesh independently. Split the part into its own object in your modelling tool and re-export, or use a morph target if the change is a deformation.
Does this approach work in Babylon.js or Unity?
The binding model of topic, path, transform, node and kind is engine-independent. Only the apply step changes, using each engine's own APIs for transforms, materials and animations.



