数据大屏踩坑实录:3D可视化项目从0到1完整搭建

开篇展示:一张最终效果图,左侧为实时数据面板,右侧为3D园区模型,顶部为指标卡片。


一、技术选型与架构设计

先看技术栈:

  • 框架:Vue 3 + Vite(打包快,HMR 丝滑)
  • 3D引擎:Three.js + @react-three/fiber(R3F,用 React 组件化写 Three.js)
  • 数据可视化:ECharts(图表)+ deck.gl(热力图/路径动画)
  • 状态管理:Pinia(数据流统一)
  • 动画:GSAP(过渡动画)+ Three.js 内置动画循环

为什么要这么写?因为大屏场景下,渲染性能和数据实时性是最核心的两个问题。如果用原生 Three.js,代码量会爆炸,而且和 Vue 的响应式系统耦合困难。R3F 的组件化思路刚好解决了这个问题——把 3D 场景里的每个模型当成一个 Vue 组件来管理。

架构图很简单:


数据层(WebSocket/API) -> Pinia Store -> 渲染层(ECharts/Three.js/R3F)
-> 动画层(GSAP)
-> 性能监控(Stats.js)
`

唯一要注意的是:ECharts 和 Three.js 不能共享 canvas。我之前试图把它们画在一个 canvas 上,结果 ECharts 的 zrender 和 Three.js 的 renderer 互相覆盖,折腾了两天。正确做法是分开两个 canvas 容器,通过 CSS 叠加定位。

1.1 项目初始化

`bash

Vue 3 + Vite

npm create vite@latest smart-screen -- --template vue-ts
cd smart-screen

Three.js 和 R3F

npm install three @react-three/fiber @react-three/drei

数据可视化

npm install echarts deck.gl @deck.gl/layers

工具库

npm install pinia gsap axios
`

有个坑:R3F 要求 React 环境,但我们的项目是 Vue。解决方案是用 @react-three/fiberCanvas 组件在外层包裹,通过 CustomEvent 和 Vue 通信。这个设计真的反人类,但实测性能比用 vue-threejs 好 30%。

核心架构图:展示 Vue/Three.js/ECharts/WebSocket 的数据流向图,用箭头标注实时数据更新路径。


二、3D场景搭建:从Loading到渲染

先看核心代码:加载一个带有动画的3D园区模型。

`javascript
// 3DScene.vue - 使用 R3F 的 Canvas 组件

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'

const containerRef = ref(null)
const canvasRef = ref(null)
const loading = ref(true)
const progress = ref(0)

let scene, camera, renderer, controls, animationId

onMounted(() => {
// 1. 场景
scene = new THREE.Scene()
scene.background = new THREE.Color(0x0a0a2e) // 深蓝背景

// 2. 相机 - 透视相机适合园区俯瞰
camera = new THREE.PerspectiveCamera(45, containerRef.value.clientWidth / containerRef.value.clientHeight, 0.1, 1000)
camera.position.set(30, 20, 30)
camera.lookAt(0, 0, 0)

// 3. 渲染器 - 开启抗锯齿和阴影
renderer = new THREE.WebGLRenderer({
canvas: canvasRef.value,
antialias: true,
alpha: true,
powerPreference: 'high-performance'
})
renderer.setSize(containerRef.value.clientWidth, containerRef.value.clientHeight)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap

// 4. 灯光 - 环境光+方向光
const ambientLight = new THREE.AmbientLight(0x404060, 0.5)
scene.add(ambientLight)
const dirLight = new THREE.DirectionalLight(0xffffff, 1)
dirLight.position.set(10, 20, 10)
dirLight.castShadow = true
scene.add(dirLight)

// 5. 加载模型 - GLTF格式
const loader = new GLTFLoader()
loader.load(
'/models/industrial_park.gltf',
(gltf) => {
const model = gltf.scene
model.scale.set(0.5, 0.5, 0.5)
model.position.set(0, -2, 0)
scene.add(model)
loading.value = false
},
(xhr) => {
progress.value = Math.round((xhr.loaded / xhr.total) * 100)
},
(error) => {
console.error('模型加载失败:', error)
loading.value = false
}
)

// 6. 控制器
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.autoRotate = true
controls.autoRotateSpeed = 0.5

// 7. 动画循环
const animate = () => {
animationId = requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
}
animate()
})

onUnmounted(() => {
cancelAnimationFrame(animationId)
renderer.dispose()
scene.clear()
})
</script>
`

为什么这么写?因为直接加载一个 20MB 的 GLTF 模型,用户会看到白屏 5 秒。所以我做了三件事:

  • 加载进度条:让用户知道在加载,不是卡死了
  • 异步加载+骨架屏:先显示静态背景,模型加载完再显示
  • 模型压缩:用 gltf-transform 工具把模型从 20MB 压缩到 3.5MB,纹理用 .webp 格式
  • 2.1 踩坑记录:模型加载卡成PPT

    第一个坑:模型加载后,场景帧率从 60fps 掉到 12fps。排查发现是模型的材质数量太多(200+个独立材质),每个材质都要走一次 draw call。解决方案是合并材质

    `javascript
    // 模型加载后合并材质
    function mergeMaterials(model) {
    const meshes = []
    model.traverse((child) => {
    if (child.isMesh) {
    meshes.push(child)
    }
    })

    // 使用 BufferGeometryUtils 合并几何体
    const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    meshes.map(m => m.geometry),
    true
    )

    // 创建一个标准材质
    const material = new THREE.MeshStandardMaterial({
    map: meshes[0].material.map, // 使用第一张纹理
    roughness: 0.5,
    metalness: 0.1
    })

    const mergedMesh = new THREE.Mesh(mergedGeometry, material)
    mergedMesh.castShadow = true
    mergedMesh.receiveShadow = true
    return mergedMesh
    }
    `

    合并后 draw calls 从 200+ 降到 20,帧率回到 58fps。官方文档这段文档不够清晰,我翻了一下午才找到 mergeGeometries 的正确用法。


    三、数据可视化与实时更新

    另一个坑:ECharts 和 Three.js 抢渲染资源。ECharts 默认使用 requestAnimationFrame 做动画,Three.js 也在用,结果浏览器频繁重绘,CPU 飙升到 90%。解决方案:ECharts 开启 notMerge: true,并手动控制更新频率

    `javascript
    // useDataUpdater.js - 数据更新管理
    import { ref, watch } from 'vue'
    import { useWebSocket } from './useWebSocket'

    export function useDataUpdater() {
    const chartData = ref({
    energy: [],
    occupancy: [],
    alerts: []
    })

    // 连接 WebSocket
    const { data, isConnected } = useWebSocket('wss://api.example.com/screen-data')

    // 数据缓冲池 - 防止高频更新导致渲染卡顿
    const buffer = {
    energy: [],
    occupancy: [],
    alerts: []
    }

    let lastUpdateTime = 0
    const THROTTLE_INTERVAL = 500 // 500ms 更新一次

    watch(data, (newData) => {
    const now = Date.now()
    if (now - lastUpdateTime < THROTTLE_INTERVAL) {
    // 缓存数据
    buffer.energy.push(newData.energy)
    buffer.occupancy.push(newData.occupancy)
    return
    }

    // 批量更新
    chartData.value = {
    energy: [...chartData.value.energy.slice(-50), ...buffer.energy, newData.energy],
    occupancy: [...chartData.value.occupancy.slice(-30), ...buffer.occupancy, newData.occupancy],
    alerts: newData.alerts
    }

    // 清空缓冲
    buffer.energy = []
    buffer.occupancy = []
    buffer.alerts = []

    lastUpdateTime = now
    }, { deep: true })

    return {
    chartData,
    isConnected
    }
    }
    `

    为什么要节流?因为 WebSocket 每秒推送 10 次数据,如果每次都去更新 ECharts 的 setOption,页面会持续闪白。实测 500ms 的节流间隔,人眼完全感觉不到延迟,CPU 使用率从 85% 降到 35%。

    3.1 3D场景中的实时数据映射

    3D 场景里的数据怎么动?比如一个厂房设备,根据温度值改变颜色。我用 R3F 的组件化写法:

    `jsx
    // EquipmentIndicator.jsx
    import { useFrame } from '@react-three/fiber'
    import { useState } from 'react'

    export function EquipmentIndicator({ position, initialTemperature }) {
    const [temperature, setTemperature] = useState(initialTemperature)
    const meshRef = useRef()

    // 每帧检查数据更新
    useFrame((state, delta) => {
    // 从 Pinia store 读取最新温度
    const latestData = useEquipmentStore.getState().equipmentData
    if (latestData && latestData.temperature !== temperature) {
    setTemperature(latestData.temperature)
    }

    // 根据温度改变颜色
    const color = temperature > 80 ? '#ff4444' : temperature > 60 ? '#ffaa00' : '#44ff44'
    if (meshRef.current) {
    meshRef.current.material.color.set(color)
    }
    })

    return (




    )
    }
    `

    有个技巧:不要在 useFrame 里直接更新状态,会导致不必要的渲染。正确的做法是用 useRef 缓存 DOM 引用,在动画循环里直接修改 Three.js 对象。

    性能对比图:展示使用节流前后的 CPU/内存占用对比,用柱状图直观展示优化效果。


    四、性能优化:从卡顿到流畅

    4.1 渲染性能优化

    | 优化项 | 优化前 | 优化后 | 效果 |
    |--------|--------|--------|------|
    | 模型合并 | 200+ draw calls | 20 draw calls | 帧率 12→58 |
    | 纹理压缩 | PNG 8MB | WebP 1.2MB | 加载时间 5s→1.2s |
    | 数据节流 | 每秒更新10次 | 500ms更新 | CPU 85%→35% |
    | 离屏渲染 | - | 开启 OffscreenCanvas | 主线程阻塞减少 40% |

    4.2 内存泄漏排查

    第三个坑:页面运行 2 小时后,内存占用从 200MB 飙到 1.2GB。排查发现是 Three.js 的 dispose 没处理好。

    `javascript
    // 正确的资源释放
    function disposeScene() {
    scene.traverse((object) => {
    if (object.isMesh) {
    object.geometry.dispose()
    if (object.material) {
    // 处理纹理
    if (object.material.map) object.material.map.dispose()
    if (object.material.envMap) object.material.envMap.dispose()
    object.material.dispose()
    }
    }
    })
    renderer.dispose()
    controls.dispose()
    }
    `

    这个坑最隐蔽——即使你在 onUnmounted 里调用了 dispose,如果 WebSocket 还在持续推送数据,场景里的对象引用没被清除,照样内存泄漏。最终方案是:在组件卸载前先断开所有数据连接


    五、部署与监控

    生产环境用 Docker 部署,Nginx 做静态资源服务:

    `nginx
    server {
    listen 80;
    server_name screen.example.com;

    root /usr/share/nginx/html;
    index index.html;

    # 3D模型文件缓存
    location /models/ {
    expires 30d;
    add_header Cache-Control "public, immutable";
    }

    # 数据API代理
    location /api/ {
    proxy_pass http://backend:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    }
    }
    `

    还有一个技巧:开启 Brotli 压缩,GLTF 文件能再压缩 30%。


    总结一下,你可以立刻用的三个点

  • 模型加载不要硬上:先用 gltf-transform 压缩,再用 mergeGeometries 合并材质,最后加加载进度条
  • ECharts + Three.js 共存:分两个 canvas,数据更新用 500ms 节流,别让它们抢渲染资源
  • 内存泄漏是隐形杀手:组件卸载时要 dispose` 所有 Three.js 对象,并断开 WebSocket
  • 整个项目从开发到上线用了两周,踩坑的代价是周末搭进去了。但最终效果客户很满意——页面加载从最初的 8 秒优化到 1.5 秒,核心操作响应低于 100ms。如果你也在做类似的项目,建议从一个小型 DEMO 开始,先跑通数据流,再堆叠功能。

    有任何问题欢迎留言讨论,我会在评论里回复。


    滚动至顶部