一、大屏可视化是什么 大屏可视化是指在超大分辨率(通常 1920×1080 到 15360×4320)的显示设备上,通过 3D 渲染和数据图表实时展示业务数据的系统。常见于智慧城市、工业监控、指挥中心、展览展示等场景。
大屏场景的特点 1 2 3 4 5 6 ├── 超宽分辨率:单屏 1920×1080,拼接墙常达 4K~8K 甚至更高 ├── 远距离观看:通常从 2-10 米外观看,文字和 UI 需要更大字号 ├── 实时数据:展示的是实时/准实时的业务数据 ├── 长时运行:7×24 小时不间断运行,需考虑内存泄漏 ├── 无交互为主:大多只展示,少数支持触控/鼠标交互 └── 视觉冲击:需要酷炫的视觉效果(3D 场景、粒子系统、光效)
技术选型 库 用途 优势 劣势 Three.js 3D 场景渲染 生态完善、文档丰富、社区活跃 包较大、移动端性能一般 ECharts 2D 图表 内置地图、大数据量优化、交互丰富 不支持 3D 场景 ECharts GL 3D 图表 基于 Three.js 的 3D 图表 维护较少 CSS 3D Transform 轻量 3D 无需 GPU 知识、浏览器原生 功能有限 Deck.gl 大数据 GIS Uber 出品、百万级点渲染 学习曲线高 Babylon.js 3D 游戏/VR 功能更完善、Havok 物理引擎 社区不及 Three.js
二、大屏适配方案 2.1 分辨率适配 大屏的拼接方式和物理分辨率差异很大,前端需要一套统一的缩放方案。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function setupScale (designWidth = 1920 , designHeight = 1080 ) { const scaleX = window .innerWidth / designWidth; const scaleY = window .innerHeight / designHeight; const scale = Math .min(scaleX, scaleY); const app = document .getElementById('app' ); app.style.transform = `scale(${scale} )` ; app.style.transformOrigin = 'left top' ; app.style.width = designWidth + 'px' ; app.style.height = designHeight + 'px' ; const offsetX = (window .innerWidth - designWidth * scale) / 2 ; const offsetY = (window .innerHeight - designHeight * scale) / 2 ; app.style.marginLeft = offsetX + 'px' ; app.style.marginTop = offsetY + 'px' ; } window .addEventListener('resize' , () => setupScale(1920 , 1080 ));setupScale(1920 , 1080 );
2.2 Three.js 场景适配 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const camera = new THREE.PerspectiveCamera(45 , window .innerWidth / window .innerHeight, 0.1 , 1000 );function onResize ( ) { const width = window .innerWidth; const height = window .innerHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height); renderer.setPixelRatio(Math .min(window .devicePixelRatio, 2 )); } window .addEventListener('resize' , onResize);
三、大屏 3D 可视化常见形态 3.1 科技感三维地图 城市级别 3D 地图是大屏中最常见的形态之一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 function createBuildings (cityData ) { const buildingGroup = new THREE.Group(); cityData.forEach((block ) => { const geometry = new THREE.BoxGeometry(block.width, block.height, block.depth); const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(block.color), emissive: new THREE.Color(0x0066ff ), emissiveIntensity: 0.2 , transparent: true , opacity: 0.85 , }); const building = new THREE.Mesh(geometry, material); building.position.set(block.x, block.height / 2 , block.z); buildingGroup.add(building); }); return buildingGroup; } const grid = new THREE.GridHelper(200 , 20 , 0x00aaff , 0x0044aa );grid.position.y = 0 ; scene.add(grid); const groundGeo = new THREE.PlaneGeometry(200 , 200 );const groundMat = new THREE.MeshBasicMaterial({ color: 0x001a33 , transparent: true , opacity: 0.6 , side: THREE.DoubleSide, }); const ground = new THREE.Mesh(groundGeo, groundMat);ground.rotation.x = -Math .PI / 2 ; ground.position.y = -0.1 ; scene.add(ground);
3.2 流动光效(线条/粒子) 流动的光线效果在大屏中常用于表示数据流向、网络连接:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 const flowShaderMaterial = new THREE.ShaderMaterial({ uniforms: { uTime: { value : 0 }, uColor: { value : new THREE.Color(0x00aaff ) }, }, vertexShader: ` varying float vProgress; void main() { vec3 pos = position; vProgress = pos.z; // 用 z 坐标作为进度 gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } ` , fragmentShader: ` uniform vec3 uColor; uniform float uTime; varying float vProgress; void main() { float flow = fract(vProgress * 2.0 - uTime * 0.5); float alpha = smoothstep(0.0, 0.1, flow) * smoothstep(1.0, 0.9, flow); gl_FragColor = vec4(uColor, alpha * 0.8); } ` , transparent: true , blending: THREE.AdditiveBlending, depthWrite: false , }); function animate ( ) { flowShaderMaterial.uniforms.uTime.value += 0.01 ; requestAnimationFrame(animate); renderer.render(scene, camera); }
3.3 粒子系统 粒子系统在大屏中用于星空背景、数据点阵、动态散点图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 function createParticleField (count = 5000 ) { const geometry = new THREE.BufferGeometry(); const positions = new Float32Array (count * 3 ); const colors = new Float32Array (count * 3 ); const sizes = new Float32Array (count); for (let i = 0 ; i < count; i++) { positions[i * 3 ] = (Math .random() - 0.5 ) * 600 ; positions[i * 3 + 1 ] = (Math .random() - 0.5 ) * 400 ; positions[i * 3 + 2 ] = (Math .random() - 0.5 ) * 600 - 200 ; const brightness = 0.3 + Math .random() * 0.7 ; colors[i * 3 ] = brightness * 0.4 ; colors[i * 3 + 1 ] = brightness * 0.6 ; colors[i * 3 + 2 ] = brightness * 1.0 ; sizes[i] = 0.5 + Math .random() * 2 ; } geometry.setAttribute('position' , new THREE.BufferAttribute(positions, 3 )); geometry.setAttribute('color' , new THREE.BufferAttribute(colors, 3 )); geometry.setAttribute('size' , new THREE.BufferAttribute(sizes, 1 )); const material = new THREE.PointsMaterial({ size: 1.5 , vertexColors: true , transparent: true , opacity: 0.8 , blending: THREE.AdditiveBlending, depthWrite: false , sizeAttenuation: true , }); return new THREE.Points(geometry, material); } scene.add(createParticleField(8000 ));
3.4 3D 柱状图 / 折线图 用 Three.js 构建 3D 柱状图比传统 ECharts 更具视觉冲击力:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function create3DBarChart (data ) { const group = new THREE.Group(); const barWidth = 1 ; const gap = 0.5 ; const totalWidth = data.length * (barWidth + gap) - gap; data.forEach((item, i ) => { const height = item.value / 100 * 20 ; const geo = new THREE.BoxGeometry(barWidth, height, barWidth); const mat = new THREE.MeshPhongMaterial({ color: new THREE.Color(item.color || 0x00aaff ), emissive: new THREE.Color(item.color || 0x00aaff ), emissiveIntensity: 0.3 , transparent: true , opacity: 0.9 , }); const bar = new THREE.Mesh(geo, mat); const x = i * (barWidth + gap) - totalWidth / 2 ; bar.position.set(x, height / 2 , 0 ); group.add(bar); }); return group; }
3.5 3D 地球 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 function createEarth ( ) { const loader = new THREE.TextureLoader(); const earthTexture = loader.load('/textures/earth.jpg' ); const glowTexture = loader.load('/textures/glow.png' ); const geometry = new THREE.SphereGeometry(5 , 64 , 64 ); const material = new THREE.MeshPhongMaterial({ map: earthTexture, specular: new THREE.Color(0x333333 ), shininess: 5 , }); const earth = new THREE.Mesh(geometry, material); const glowGeometry = new THREE.SphereGeometry(5.2 , 64 , 64 ); const glowMaterial = new THREE.ShaderMaterial({ vertexShader: ` varying vec3 vNormal; void main() { vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } ` , fragmentShader: ` varying vec3 vNormal; void main() { float intensity = pow(0.6 - dot(vNormal, vec3(0, 0, 1.0)), 2.0); gl_FragColor = vec4(0.3, 0.6, 1.0, intensity * 0.8); } ` , transparent: true , blending: THREE.AdditiveBlending, side: THREE.BackSide, }); const glow = new THREE.Mesh(glowGeometry, glowMaterial); const group = new THREE.Group(); group.add(earth); group.add(glow); return group; }
四、大屏性能优化 4.1 Three.js 性能优化清单 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ├── 合并几何体 │ 大量相同几何体时用 BufferGeometryUtils.mergeGeometries 合并 ├── 使用 InstancedMesh │ 相同几何体不同位置用 InstancedMesh(一次 draw call 渲染所有实例) ├── 限制像素比 │ renderer.setPixelRatio(Math.min(devicePixelRatio, 2)) ├── 开启 Frustum Culling │ 默认开启,相机外的物体不渲染 ├── LOD(Level of Detail) │ 远处用低面模型,近处用高面模型 ├── 纹理优化 │ └── 纹理尺寸为 2 的幂(256/512/1024)以减少 GPU 开销 ├── 关闭阴影 │ renderer.shadowMap.enabled = false(大屏中很少需要实时阴影) ├── 使用 BufferGeometry │ 始终使用 BufferGeometry(Geometry 已废弃) ├── 对象池 │ 对于频繁创建/销毁的对象,用对象池复用 └── dispose 清理 不再使用的 geometry、material、texture 手动 dispose
4.2 内存管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function disposeObject (obj ) { if (obj.geometry) { obj.geometry.dispose(); } if (obj.material) { if (Array .isArray(obj.material)) { obj.material.forEach(m => m.dispose()); } else { obj.material.dispose(); } } if (obj.texture) { obj.texture.dispose(); } } function clearScene (scene ) { while (scene.children.length > 0 ) { const child = scene.children[0 ]; scene.remove(child); disposeObject(child); } }
4.3 帧率控制 大屏通常是 7×24 运行,不一定需要 60fps,30fps 即可满足视觉效果,且能降低 GPU 负载:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let lastTime = 0 ;const FPS_LIMIT = 30 ;const INTERVAL = 1000 / FPS_LIMIT;function animate (timestamp ) { requestAnimationFrame(animate); const delta = timestamp - lastTime; if (delta < INTERVAL) return ; lastTime = timestamp - (delta % INTERVAL); updateAnimation(); renderer.render(scene, camera); }
4.4 数据驱动的场景更新 大屏展示实时数据,场景需要随数据变化而更新:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 class DataBindingManager { constructor ( ) { this .bindings = new Map (); } bind (key, target, prop, format = v => v ) { this .bindings.set(key, { target, prop, format }); } update (data ) { for (const [key, value] of Object .entries(data)) { const binding = this .bindings.get(key); if (binding) { binding.target[binding.prop] = binding.format(value); } } } clear ( ) { this .bindings.clear(); } } const manager = new DataBindingManager();manager.bind('temperature' , barMesh, 'scale.y' , v => new THREE.Vector3(1 , v / 100 , 1 )); manager.bind('sales' , textSprite, 'material.map' , v => generateLabel(`${v} 万` )); socket.on('data' , (data ) => { manager.update(data); });
五、大屏项目架构 5.1 推荐项目结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 big-screen/ ├── public/ │ └── textures/ ├── src/ │ ├── index.html │ ├── main.js # 入口 —— 初始化场景/相机/渲染器 │ ├── App.vue # 布局组件(可选框架) │ ├── config/ │ │ ├── screen.js # 屏幕参数(分辨率/缩放/设备) │ │ └── theme.js # 主题色配置 │ ├── scene/ │ │ ├── SceneManager.js # 场景管理器 │ │ ├── CameraController.js │ │ └── lights.js │ ├── modules/ # 各可视化模块 │ │ ├── Earth/ │ │ │ ├── Earth3D.js # 3D 地球 │ │ │ └── config.js │ │ ├── BarChart3D/ │ │ │ ├── BarChart3D.js │ │ │ └── config.js │ │ └── Particle/ │ │ ├── ParticleBackground.js │ │ └── config.js │ ├── effects/ # 特效 │ │ ├── FlowLine.js # 流动光效 │ │ └── GlowEffect.js # 泛光效果 │ ├── data/ # 数据管理 │ │ ├── DataManager.js # WebSocket 连接 + 数据分发 │ │ └── mockData.js │ ├── utils/ │ │ ├── dispose.js │ │ └── scale.js │ └── styles/ │ └── global.css ├── package.json └── vite.config.js
5.2 场景管理器 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 class SceneManager { constructor (container ) { this .scene = new THREE.Scene(); this .camera = new THREE.PerspectiveCamera(45 , window .innerWidth / window .innerHeight, 0.1 , 2000 ); this .renderer = new THREE.WebGLRenderer({ antialias: true , alpha: true , }); this .renderer.setSize(window .innerWidth, window .innerHeight); this .renderer.setPixelRatio(Math .min(window .devicePixelRatio, 2 )); this .renderer.toneMapping = THREE.ACESFilmicToneMapping; this .renderer.toneMappingExposure = 1.2 ; container.appendChild(this .renderer.domElement); this .camera.position.set(30 , 20 , 40 ); this .camera.lookAt(0 , 0 , 0 ); this .modules = new Map (); this .clock = new THREE.Clock(); } addModule (name, module ) { this .modules.set(name, module ); if (module .onAdd) module .onAdd(this .scene); } removeModule (name ) { const module = this .modules.get(name); if (module && module .onRemove) module .onRemove(this .scene); this .modules.delete(name); } updateData (data ) { this .modules.forEach(module => { if (module .onData) module .onData(data); }); } start ( ) { const animate = () => { requestAnimationFrame(animate); const delta = this .clock.getDelta(); this .modules.forEach(module => { if (module .onUpdate) module .onUpdate(delta); }); this .renderer.render(this .scene, this .camera); }; animate(); } resize (width, height ) { this .camera.aspect = width / height; this .camera.updateProjectionMatrix(); this .renderer.setSize(width, height); } dispose ( ) { this .modules.forEach(module => { if (module .onRemove) module .onRemove(this .scene); }); this .renderer.dispose(); } }
5.3 数据流设计 1 2 3 4 5 6 7 8 9 10 11 12 WebSocket / MQTT / HTTP Polling │ ├── DataManager(统一接收、解析、缓存) │ ├── ECharts 图表模块 │ └── chart.setOption(newData) │ ├── Three.js 3D 模块 │ └── DataBindingManager.update(newData) │ └── 面板模块(文字/KPI) └── DOM 更新
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 class DataManager { constructor ( ) { this .subscribers = new Map (); this .cache = {}; } subscribe (moduleName, keys, callback ) { keys.forEach(key => { if (!this .subscribers.has(key)) { this .subscribers.set(key, []); } this .subscribers.get(key).push({ moduleName, callback }); }); } onMessage (message ) { const { type, payload } = message; this .cache[type] = payload; const subs = this .subscribers.get(type); if (subs) { subs.forEach(({ callback } ) => callback(payload)); } } }
六、渲染增强效果 6.1 后处理——泛光(Bloom) 1 npm install three/examples/jsm/postprocessing/EffectComposer.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js' ;import { RenderPass } from 'three/addons/postprocessing/RenderPass.js' ;import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js' ;const composer = new EffectComposer(renderer);const renderPass = new RenderPass(scene, camera);composer.addPass(renderPass); const bloomPass = new UnrealBloomPass( new THREE.Vector2(window .innerWidth, window .innerHeight), 0.5 , 0.4 , 0.85 ); composer.addPass(bloomPass);
6.2 CSS2DRenderer——标签与文字 1 npm install three/examples/jsm/renderers/CSS2DRenderer.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js' ;const labelRenderer = new CSS2DRenderer();labelRenderer.setSize(window .innerWidth, window .innerHeight); labelRenderer.domElement.style.position = 'absolute' ; labelRenderer.domElement.style.top = '0' ; labelRenderer.domElement.style.pointerEvents = 'none' ; container.appendChild(labelRenderer.domElement); function createLabel (text, color = '#00aaff' ) { const div = document .createElement('div' ); div.textContent = text; div.style.color = color; div.style.fontSize = '16px' ; div.style.fontWeight = 'bold' ; div.style.textShadow = '0 0 10px rgba(0,170,255,0.5)' ; return new CSS2DObject(div); } const label = createLabel('北京' , '#ff6600' );label.position.set(10 , 5 , 0 ); scene.add(label); function animate ( ) { renderer.render(scene, camera); labelRenderer.render(scene, camera); requestAnimationFrame(animate); }
七、大屏开发注意事项 7.1 字体与视觉 1 2 3 4 5 6 7 body { font-family : 'Microsoft YaHei' , 'PingFang SC' , sans-serif; user-select: none; -webkit-user-select: none; }
1 2 3 4 5 6 ├── 字号 ≥ 14px(观看距离远,过小文字不可读) ├── 标题 24-48px,数据值 32-72px ├── 颜色对比度要提高 ├── 深色主题为主(减少发光,降低功耗) ├── 避免纯白背景(大屏亮度高,白色刺眼) └── 使用发光/半透明效果增强科技感
7.2 长时间运行 1 2 3 4 5 6 7 8 9 10 ├── 内存泄漏检测 │ └── Chrome DevTools → Performance → Memory 录制查看 ├── 定时器管理 │ └── 所有 setInterval 在页面隐藏时暂停 ├── requestAnimationFrame 暂停 │ └── Page Visibility API — hidden 时停止渲染 ├── 纹理缓存清理 │ └── 定期 dispose 不再使用的纹理 └── 避免 DOM 操作过于频繁 └── 虚拟 DOM 合并更新
1 2 3 4 5 6 7 8 9 10 document .addEventListener('visibilitychange' , () => { if (document .hidden) { this .isPaused = true ; } else { this .isPaused = false ; this .clock.start(); } });
7.3 数据刷新策略 1 2 3 4 5 6 7 8 ├── 实时数据(秒级)→ WebSocket / MQTT │ └── 增量更新,不重建场景 ├── 准实时数据(分钟级)→ 轮询 HTTP │ └── 对比变化后更新,不变的不更新 ├── 静态数据 → 构建时生成 │ └── 写死在配置文件中,不请求网络 └── 数据降级 → 断线时展示上次缓存 └── localStorage / IndexedDB 缓存最近一次数据
八、常见问题 Q1: 大屏 3D 性能不够怎么办 1 2 3 4 5 6 7 8 1. 检查像素比:renderer.setPixelRatio(Math.min(dpr, 2)) 2. 合并几何体:避免大量独立的 Mesh 3. 用 InstancedMesh:相同模型不同位置 4. 降低多边形:减少 SphereGeometry 分段数 5. 限制帧率:30fps 足矣 6. 移除阴影:大屏几乎不需要动态阴影 7. 使用 LOD:远处用低面模型 8. 检查后处理:Bloom 等后处理很消耗性能
Q2: 大屏上的文字太小怎么办 1 2 3 4 5 6 文字大小由两个因素决定: 1. 设计稿分辨率:按 1920×1080 设计,通过 scale 缩放 2. 观看距离:3 米外至少 24px,5 米外至少 36px 使用 CSS2DRenderer 渲染的 3D 标签,其大小跟随场景比例而非屏幕像素, 需要通过 camera.position 距离调节。
Q3: ECharts 和 Three.js 怎么结合使用 1 2 3 4 5 6 7 8 常见方案: 1. ECharts 与 Three.js 各占独立区域,互不干扰 2. ECharts 图表作为 CSS 覆盖层(position: absolute)悬浮在 Three.js 场景上方 3. 使用 ECharts GL 在 Three.js 场景中渲染 3D 图表 4. Three.js 作为背景(地图/城市),ECharts 作为前景图表 推荐方案 2:Three.js 渲染 3D 场景,position: absolute 的 div 层 放置 ECharts 图表,两者通过数据管理层统一驱动。