大屏 3D 可视化开发

一、大屏可视化是什么

大屏可视化是指在超大分辨率(通常 1920×1080 到 15360×4320)的显示设备上,通过 3D 渲染和数据图表实时展示业务数据的系统。常见于智慧城市、工业监控、指挥中心、展览展示等场景。

大屏场景的特点

1
2
3
4
5
6
├── 超宽分辨率:单屏 1920×1080,拼接墙常达 4K~8K 甚至更高
├── 远距离观看:通常从 2-10 米外观看,文字和 UI 需要更大字号
├── 实时数据:展示的是实时/准实时的业务数据
├── 长时运行:7×24 小时不间断运行,需考虑内存泄漏
├── 无交互为主:大多只展示,少数支持触控/鼠标交互
└── 视觉冲击:需要酷炫的视觉效果(3D 场景、粒子系统、光效)

技术选型

用途优势劣势
Three.js3D 场景渲染生态完善、文档丰富、社区活跃包较大、移动端性能一般
ECharts2D 图表内置地图、大数据量优化、交互丰富不支持 3D 场景
ECharts GL3D 图表基于 Three.js 的 3D 图表维护较少
CSS 3D Transform轻量 3D无需 GPU 知识、浏览器原生功能有限
Deck.gl大数据 GISUber 出品、百万级点渲染学习曲线高
Babylon.js3D 游戏/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
// scale.js —— 基于视口缩放的适配方案
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
// 流动光线的 ShaderMaterial
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
// 创建 3D 地球(需 earth.jpg 贴图)
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
// 切换场景或卸载组件时必须清理 Three.js 资源
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(); // 数据 key → { target, prop, format }
}

// 绑定数据到对象属性
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}万`));

// WebSocket 收到新数据时
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
// data/DataManager.js
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 });
});
}

// 接收 WebSocket 数据并分发给订阅者
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, // strength — 泛光强度
0.4, // radius
0.85 // threshold
);
composer.addPass(bloomPass);

// 动画循环中用 composer.render() 替代 renderer.render()

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);

// 动画循环中同时渲染 WebGL 和 CSS2D
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 图表,两者通过数据管理层统一驱动。