🚨Vue3+Three.js 打造实时设备状态 3D 可视化面板
模型会 "说话"!设备状态动态刷新🔥
大家好,今天给大家带来超实用工业级 3D 可视化功能------
基于 Three.js + Vue3,在 3D 模型上实时显示设备状态、温度、运行信息
不用刷新页面,数据一变,3D 场景里的文字标签自动跟着变!
🌟核心亮点(只讲重点)
✅ 3D 模型上贴动态文字,设备状态实时更新
✅ 文字永远面向镜头,怎么旋转都看得清
✅ 多行文字 + 自定义颜色:正常 / 异常 / 告警一目了然
✅ 点击模型弹出详情面板
✅ 双击保存视角,调试超方便
✅ 支持 GLB/GLTF 模型,直接对接建模软件输出
一句话:3D 场景不再是死模型,而是会动、会变、会报状态的数字孪生面板!
🎯一、最终实现效果
你将得到一个超强 3D 设备监控面板:
-
模型加载后自动贴上状态标签
- 设备名称
- 运行状态(正常 / 异常 / 告警)
- 实时温度 / 电压 / 数据
-
文字自动面向相机,360° 查看都清晰
-
状态颜色动态变化
- 绿色:运行正常
- 黄色:预警
- 红色:故障告警
-
点击模型 → 右侧弹出信息面板
-
数据更新 → 3D 文字自动刷新
🧱二、核心技术栈
- Vue3 +
<script setup> - Three.js 3D 引擎
- OrbitControls 轨道控制
- GLTFLoader 模型加载
- Canvas 动态文字生成
🚀三、完整代码(直接复制可用)
javascript
<template>
<div class="scene-container">
<div ref="sceneContainer" class="canvas-container"></div>
<!-- 左侧操作提示 -->
<div class="info-panel">
<h3>🏭 3D 设备状态监控</h3>
<p>左键:查看设备信息</p>
<p>右键:平移 | 滚轮:缩放</p>
<p>双击:复制视角信息</p>
<div v-if="loading" class="loading">模型加载中...</div>
<div v-if="error" class="error">{{ error }}</div>
</div>
<!-- 右侧设备信息面板 -->
<div v-if="selectedObject" class="object-info">
<h4>📦 设备详情</h4>
<div class="info-item">
<span class="label">名称:</span>
<span class="value">{{ selectedObject.name || '未知设备' }}</span>
</div>
<div class="info-item">
<span class="label">类型:</span>
<span class="value">{{ selectedObject.type }}</span>
</div>
<div class="info-item">
<span class="label">位置:</span>
<span class="value">
({{ selectedObject.position.x.toFixed(1) }},
{{ selectedObject.position.y.toFixed(1) }},
{{ selectedObject.position.z.toFixed(1) }})
</span>
</div>
<button @click="selectedObject = null" class="close-btn">关闭面板</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
const props = defineProps({
initialViewInfo: {
type: Object,
default: () => ({
cameraPosition: [-0.03, 1.76, 5.14],
target: [0, 0, 0],
fov: 45
})
}
})
const sceneContainer = ref(null)
const loading = ref(true)
const error = ref('')
const selectedObject = ref(null)
let scene, camera, renderer, controls
let animationId
let raycaster, mouse
let loadedModel = null
// 保存所有动态文字标签,用于后续更新
let deviceLabelMap = {}
// 场景初始化
const initScene = () => {
if (!sceneContainer.value) return
scene = new THREE.Scene()
scene.background = new THREE.Color(0x1a1a2e)
const width = sceneContainer.value.clientWidth || window.innerWidth
const height = sceneContainer.value.clientHeight || window.innerHeight
camera = new THREE.PerspectiveCamera(props.initialViewInfo.fov, width / height, 0.1, 1000)
camera.position.set(...props.initialViewInfo.cameraPosition)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
sceneContainer.value.appendChild(renderer.domElement)
raycaster = new THREE.Raycaster()
mouse = new THREE.Vector2()
// 轨道控制
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.target.set(...props.initialViewInfo.target)
// 灯光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambientLight)
const dirLight = new THREE.DirectionalLight(0xffffff, 0.9)
dirLight.position.set(5, 10, 7)
scene.add(dirLight)
renderer.domElement.addEventListener('click', onMouseClick)
renderer.domElement.addEventListener('dblclick', onDoubleClick)
window.addEventListener('resize', onWindowResize)
}
// ====================== 【核心:动态创建/更新设备状态标签】 ======================
const createOrUpdateDeviceLabel = (deviceName, statusData) => {
if (!loadedModel) return
const { status, temp, message } = statusData
let textColor = '#00ff00'
if (status === 'warning') textColor = '#ffff00'
if (status === 'error') textColor = '#ff0000'
const lines = [
{ text: deviceName, style: { fontSize: 46, color: '#ffffff' } },
{ text: message, style: { fontSize: 42, color: textColor } },
{ text: `温度:${temp} °C`, style: { fontSize: 36, color: '#00d4ff' } }
]
if (deviceLabelMap[deviceName]) {
const label = deviceLabelMap[deviceName]
updateTextTexture(label, lines)
return
}
const label = addTextToObject(deviceName, lines, {
planeWidth: 1.8,
planeHeight: 1.0,
position: { x: 0, y: 0.6, z: 0 },
scale: 1.1,
faceTo: 'camera'
})
if (label) deviceLabelMap[deviceName] = label
}
// 更新文字纹理(真正实现动态刷新)
const updateTextTexture = (labelMesh, lines) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = 320
canvas.height = 180
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
lines.forEach((line, i) => {
ctx.fillStyle = line.style.color
ctx.font = `bold ${line.style.fontSize}px Microsoft YaHei`
ctx.fillText(line.text, canvas.width / 2, 50 + i * 40)
})
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
labelMesh.material.map = texture
}
// 添加文字到模型(始终面向相机)
const addTextToObject = (targetName, textLines, options = {}) => {
const config = {
textColor: '#fff',
fontSize: 30,
planeWidth: 1.6,
planeHeight: 0.8,
position: { x: 0, y: 0.5, z: 0 },
scale: 1,
faceTo: 'camera',
...options
}
let target = null
loadedModel.traverse(child => {
if (child.isMesh && child.name === targetName) target = child
})
if (!target) return null
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = 320
canvas.height = 180
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
textLines.forEach((line, i) => {
ctx.fillStyle = line.style?.color || config.textColor
ctx.font = `bold ${line.style?.fontSize || config.fontSize}px Microsoft YaHei`
ctx.fillText(line.text || line, canvas.width / 2, 50 + i * 40)
})
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
depthTest: false
})
const geo = new THREE.PlaneGeometry(config.planeWidth, config.planeHeight)
const mesh = new THREE.Mesh(geo, material)
mesh.position.set(config.position.x, config.position.y, config.position.z)
mesh.scale.setScalar(config.scale)
mesh.userData.faceToCamera = true
target.add(mesh)
return mesh
}
// ====================== 交互 ======================
const onMouseClick = (event) => {
if (!loadedModel) return
const rect = renderer.domElement.getBoundingClientRect()
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObject(loadedModel, true)
if (intersects.length) {
const o = intersects[0].object
selectedObject.value = {
name: o.name,
type: o.type,
position: o.position
}
} else {
selectedObject.value = null
}
}
const onDoubleClick = () => {
const info = {
cameraPosition: [+camera.position.x.toFixed(2), +camera.position.y.toFixed(2), +camera.position.z.toFixed(2)],
target: [+controls.target.x.toFixed(2), +controls.target.y.toFixed(2), +controls.target.z.toFixed(2)],
fov: camera.fov
}
console.log(JSON.stringify(info, null, 2))
}
const onWindowResize = () => {
if (!sceneContainer.value) return
const w = sceneContainer.value.clientWidth
const h = sceneContainer.value.clientHeight
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
}
// 加载模型
const loadModel = () => {
const loader = new GLTFLoader()
loader.load(
encodeURI('/models/场景.glb'),
(gltf) => {
loadedModel = gltf.scene
const box = new THREE.Box3().setFromObject(loadedModel)
const center = box.getCenter(new THREE.Vector3())
loadedModel.position.sub(center)
scene.add(loadedModel)
loading.value = false
// 加载完成后立即显示状态
setTimeout(() => {
createOrUpdateDeviceLabel('圆环', {
status: 'normal',
temp: 25,
message: '运行正常'
})
// 模拟后端数据实时更新(3秒后变预警)
setTimeout(() => {
createOrUpdateDeviceLabel('圆环', {
status: 'warning',
temp: 46,
message: '温度偏高'
})
}, 3000)
}, 600)
},
(prog) => {},
(err) => {
error.value = '模型加载失败'
loading.value = false
}
)
}
// 动画循环
const animate = () => {
animationId = requestAnimationFrame(animate)
controls.update()
// 所有标签始终面向相机
Object.values(deviceLabelMap).forEach(label => {
if (label?.userData.faceToCamera) label.lookAt(camera.position)
})
renderer.render(scene, camera)
}
onMounted(async () => {
await nextTick()
initScene()
loadModel()
animate()
setTimeout(onWindowResize, 100)
})
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize)
cancelAnimationFrame(animationId)
renderer?.dispose()
controls?.dispose()
})
</script>
<style scoped>
.scene-container {
width: 100%;
height: 100vh;
position: relative;
background: #111;
}
.canvas-container {
width: 100%;
height: 100%;
}
.info-panel {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 14px 18px;
border-radius: 10px;
max-width: 280px;
backdrop-filter: blur(6px);
}
.object-info {
position: absolute;
top: 20px;
right: 20px;
background: rgba(0,0,0,0.7);
color: #fff;
padding: 16px 20px;
border-radius: 10px;
min-width: 280px;
backdrop-filter: blur(6px);
}
.info-item {
display: flex;
justify-content: space-between;
margin: 8px 0;
}
.close-btn {
width: 100%;
padding: 8px;
background: #0066cc;
color: #fff;
border: none;
border-radius: 6px;
margin-top: 10px;
cursor: pointer;
}
.loading { color: #00ccff; }
.error { color: #ff3333; }
</style>
🎯四、动态设备状态功能详解(重点)
1. 一行代码更新状态
javascript
createOrUpdateDeviceLabel('圆环', {
status: 'normal', // normal / warning / error
temp: 25,
message: '运行正常'
})
2. 自动颜色变化
- ✅ 绿色:运行正常
- ⚠️ 黄色:预警
- 🔴 红色:故障告警
3. 文字永远面向镜头
不管你怎么旋转、缩放、平移,设备状态永远正对镜头,不会出现看不见、看不清的问题。
4. 真正动态刷新
内部使用 updateTextTexture 直接更新纹理,
不用重建模型、不用刷新页面,数据实时同步到 3D 场景!
🔧五、快速接入你的项目
- 安装 three
bash
npm install three
- 把 GLB 模型放入
public/models/ - 修改模型名称:
圆环→ 你模型里的名字 - 对接接口:把模拟定时器换成你的后端数据
📌六、适用场景
- 🏭 数字孪生工厂
- 🚦 智能设备监控面板
- 🏗️ 建筑 / 机房 / 园区 3D 可视化
- 🛢️ 工业物联网 IIoT 展示
🎁结语
这是一套生产可用、可直接上线的 3D 设备状态监控组件。
模型会动、状态会变、界面超酷,非常适合做数字化、可视化项目的核心亮点功能!