Vue3+Three.js 打造实时设备状态 3D 可视化面板

🚨Vue3+Three.js 打造实时设备状态 3D 可视化面板

模型会 "说话"!设备状态动态刷新🔥

大家好,今天给大家带来超实用工业级 3D 可视化功能------

基于 Three.js + Vue3,在 3D 模型上实时显示设备状态、温度、运行信息

不用刷新页面,数据一变,3D 场景里的文字标签自动跟着变!


🌟核心亮点(只讲重点)

3D 模型上贴动态文字,设备状态实时更新

文字永远面向镜头,怎么旋转都看得清

多行文字 + 自定义颜色:正常 / 异常 / 告警一目了然

点击模型弹出详情面板

双击保存视角,调试超方便

支持 GLB/GLTF 模型,直接对接建模软件输出

一句话:3D 场景不再是死模型,而是会动、会变、会报状态的数字孪生面板!


🎯一、最终实现效果

你将得到一个超强 3D 设备监控面板:

  1. 模型加载后自动贴上状态标签

    • 设备名称
    • 运行状态(正常 / 异常 / 告警)
    • 实时温度 / 电压 / 数据
  2. 文字自动面向相机,360° 查看都清晰

  3. 状态颜色动态变化

    • 绿色:运行正常
    • 黄色:预警
    • 红色:故障告警
  4. 点击模型 → 右侧弹出信息面板

  5. 数据更新 → 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 场景!


🔧五、快速接入你的项目

  1. 安装 three
bash 复制代码
npm install three
  1. 把 GLB 模型放入 public/models/
  2. 修改模型名称:圆环 → 你模型里的名字
  3. 对接接口:把模拟定时器换成你的后端数据

📌六、适用场景

  • 🏭 数字孪生工厂
  • 🚦 智能设备监控面板
  • 🏗️ 建筑 / 机房 / 园区 3D 可视化
  • 🛢️ 工业物联网 IIoT 展示

🎁结语

这是一套生产可用、可直接上线的 3D 设备状态监控组件。

模型会动、状态会变、界面超酷,非常适合做数字化、可视化项目的核心亮点功能!

相关推荐
Highcharts.js2 小时前
经验值|React 实时数据图表性能为什么会越来越卡?
前端·javascript·react.js·数据可视化·实时数据
m0_716765232 小时前
C++巩固案例--通讯录管理系统详解
java·开发语言·c++·经验分享·学习·青少年编程·visual studio
jf加菲猫2 小时前
第10章 数据处理
xml·开发语言·数据库·c++·qt·ui
酉鬼女又兒2 小时前
零基础快速入门前端深入掌握箭头函数、Promise 与 Fetch API —— 蓝桥杯 Web 考点全解析(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·css·职场和发展·蓝桥杯·es6·js
迷藏4942 小时前
**发散创新:Go语言中基于上下文的优雅错误处理机制设计与实战**在现代后端开发中,**错误处理**早已不是简单
java·开发语言·后端·python·golang
2301_764441332 小时前
基于python实现的便利店投资分析财务建模评估
开发语言·python·数学建模
杰克尼2 小时前
知识点总结--day10(Spring-Cloud框架)
java·开发语言
程序员小寒2 小时前
JavaScript设计模式(七):迭代器模式实现与应用
前端·javascript·设计模式·迭代器模式
晓13132 小时前
React篇——第七章 React 19 编译器深度解析
前端·javascript·react.js