Three.js 实战:从零构建智慧城市 3D 可视化大屏,附完整交互式代码

在数据可视化领域,3D 可视化大屏 凭借其直观、震撼、信息承载量大的优势,已成为智慧城市、工业互联网、数字孪生等场景的标配。Three.js 作为 Web 端最成熟的 3D 引擎,凭借其丰富的 API 和庞大的生态,成为前端可视化工程师的必备技能。本文将带你从零搭建一个 包含动态建筑、光柱、飞线、旋转扫描、数据面板联动 的智慧城市 3D 大屏,全部代码(HTML + CSS + JavaScript)超过 2000 字符,涵盖场景构建、几何体生成、动画循环、鼠标交互、ECharts 数据面板集成等全流程。读完本文,你将具备独立开发 3D 可视化大屏的能力。


1. 系统架构与技术选型

模块 技术选型 职责
3D 引擎 Three.js (r152) 渲染场景、模型、光照、动画
辅助库 OrbitControls 相机轨道控制(可交互查看)
CSS 2D CSS2DRenderer 标签、数据面板的 HTML 覆盖
数据可视化 ECharts 2D 图表(折线图、柱状图)
构建工具 CDN 方式(无打包) 便于直接运行

功能亮点

  • 随机生成的建筑群(高度随数据变化)
  • 流动光柱动画(代表实时指标)
  • 粒子系统(模拟城市灯光)
  • 鼠标悬停显示建筑信息
  • 飞线动画(连接关键节点)
  • 动态旋转扫描环

2. 环境搭建

新建 index.html,引入 Three.js 核心库及扩展(使用 ES Modules):

html

xml 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>智慧城市 3D 可视化大屏</title>
    <style>
        /* 样式见第3节 */
    </style>
</head>
<body>
    <div id="container"></div>
    <div id="info-panel" style="display:none; position:fixed; background:rgba(0,0,0,0.8); color:#fff; padding:10px 20px; border-radius:8px; pointer-events:none; z-index:100;"></div>
    
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.152.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.152.0/examples/jsm/"
            }
        }
    </script>
    <script type="module" src="./app.js"></script>
</body>
</html>

3. 样式与布局(代码块1,约 150 字符)

css

css 复制代码
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #0a0e1a; font-family: 'Microsoft YaHei', sans-serif; }
#container { width: 100vw; height: 100vh; display: block; }

/* 2D 图表容器 - 覆盖在 3D 之上 */
.chart-panel {
    position: fixed;
    bottom: 30px;
    left: 30px;
    width: 320px;
    height: 200px;
    background: rgba(10, 14, 30, 0.75);
    border: 1px solid rgba(0, 255, 255, 0.3);
    border-radius: 12px;
    backdrop-filter: blur(8px);
    padding: 10px;
    color: #a0d0ff;
    font-size: 14px;
    z-index: 10;
    box-shadow: 0 0 30px rgba(0, 150, 255, 0.15);
}
.chart-panel .title {
    font-weight: bold;
    margin-bottom: 6px;
    font-size: 16px;
    color: #7fc7ff;
    letter-spacing: 2px;
}
#chart-container {
    width: 100%;
    height: calc(100% - 30px);
}

4. 核心 JavaScript 代码(app.js)

4.1 场景、相机、渲染器初始化(代码块2,约 180 字符)

javascript

ini 复制代码
// app.js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
import * as echarts from 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.esm.min.js';

// --- 场景 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0e1a); // 深空蓝
scene.fog = new THREE.FogExp2(0x0a0e1a, 0.0025); // 雾效增强景深

// --- 相机 (透视) ---
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(40, 30, 50);
camera.lookAt(0, 0, 0);

// --- WebGL渲染器 ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.getElementById('container').appendChild(renderer.domElement);

// --- CSS2D渲染器 (用于标签) ---
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'; // 让点击穿透
document.getElementById('container').appendChild(labelRenderer.domElement);

// --- 控制器 ---
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.8;
controls.maxPolarAngle = Math.PI / 2.4;
controls.target.set(0, 5, 0);

4.2 光照系统(代码块3,约 120 字符)

javascript

ini 复制代码
// --- 环境光(均匀照亮)---
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);

// --- 主光源(产生阴影)---
const dirLight = new THREE.DirectionalLight(0xffeedd, 1.8);
dirLight.position.set(30, 50, 20);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
const d = 60;
dirLight.shadow.camera.left = -d;
dirLight.shadow.camera.right = d;
dirLight.shadow.camera.top = d;
dirLight.shadow.camera.bottom = -d;
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 80;
scene.add(dirLight);

// --- 补光(蓝色调)---
const fillLight = new THREE.DirectionalLight(0x4488ff, 0.6);
fillLight.position.set(-30, 10, -30);
scene.add(fillLight);

// --- 地面网格辅助 ---
const gridHelper = new THREE.GridHelper(100, 20, 0x44ddff, 0x336699);
gridHelper.position.y = -0.1;
scene.add(gridHelper);

4.3 生成随机建筑群(含数据绑定)(代码块4,约 400 字符)

javascript

ini 复制代码
// 存储所有建筑对象,用于交互
const buildings = [];
const buildingData = []; // 存储每个建筑的原始数据

function createCityBlocks() {
    const group = new THREE.Group();
    const colorPalette = [0x4a7db4, 0x5a8fd4, 0x3a6d9e, 0x6a9fe8, 0x2a5d8e];
    
    // 生成 12x12 的网格,中心留出广场
    for (let i = -6; i <= 6; i++) {
        for (let j = -6; j <= 6; j++) {
            // 中心 4x4 区域留空(广场)
            if (Math.abs(i) <= 2 && Math.abs(j) <= 2) continue;
            
            // 随机高度 (2~12),代表数据值
            const height = 2 + Math.random() * 10;
            const width = 0.6 + Math.random() * 0.8;
            const depth = 0.6 + Math.random() * 0.8;
            
            // 生成 BoxGeometry
            const geo = new THREE.BoxGeometry(width, height, depth);
            const color = colorPalette[Math.floor(Math.random() * colorPalette.length)];
            const mat = new THREE.MeshStandardMaterial({
                color: color,
                emissive: new THREE.Color(color).multiplyScalar(0.15),
                roughness: 0.4,
                metalness: 0.1,
                transparent: true,
                opacity: 0.92
            });
            const mesh = new THREE.Mesh(geo, mat);
            
            // 位置(随机偏移+网格对齐)
            const x = i * 1.8 + (Math.random() - 0.5) * 0.4;
            const z = j * 1.8 + (Math.random() - 0.5) * 0.4;
            mesh.position.set(x, height/2, z); // 底部在 y=0
            mesh.castShadow = true;
            mesh.receiveShadow = true;
            
            // 存储自定义数据
            mesh.userData = {
                id: `bld_${i}_${j}`,
                height: height,
                value: Math.round((height - 2) * 10 + 20), // 模拟指标
                name: `建筑 ${i}${j}`
            };
            buildingData.push(mesh.userData);
            
            group.add(mesh);
            buildings.push(mesh);
            
            // 添加屋顶发光小方块(增强科技感)
            if (height > 6) {
                const topGeo = new THREE.BoxGeometry(0.3, 0.1, 0.3);
                const topMat = new THREE.MeshStandardMaterial({
                    color: 0x88ddff,
                    emissive: 0x4488ff,
                    emissiveIntensity: 0.8
                });
                const top = new THREE.Mesh(topGeo, topMat);
                top.position.set(x, height + 0.05, z);
                group.add(top);
            }
        }
    }
    scene.add(group);
}
createCityBlocks();

4.4 动态光柱(代表实时流量)(代码块5,约 220 字符)

javascript

ini 复制代码
// 生成 6 个光柱,环绕广场
const pillarGroup = new THREE.Group();
const pillarPositions = [
    [-3, 0, -3], [3, 0, -3], [-3, 0, 3], [3, 0, 3], [0, 0, -4], [0, 0, 4]
];

pillarPositions.forEach((pos) => {
    const heightBase = 4 + Math.random() * 4;
    const geo = new THREE.CylinderGeometry(0.2, 0.6, heightBase, 8);
    const mat = new THREE.MeshStandardMaterial({
        color: 0x00ccff,
        emissive: 0x0088ff,
        emissiveIntensity: 0.6,
        transparent: true,
        opacity: 0.7
    });
    const pillar = new THREE.Mesh(geo, mat);
    pillar.position.set(pos[0], heightBase/2, pos[1]);
    pillar.userData.baseHeight = heightBase;
    pillar.userData.phase = Math.random() * Math.PI * 2;
    pillar.castShadow = true;
    pillarGroup.add(pillar);
});
scene.add(pillarGroup);

4.5 粒子系统(城市灯光/星空)(代码块6,约 160 字符)

javascript

ini 复制代码
function createParticles() {
    const count = 2000;
    const positions = new Float32Array(count * 3);
    const colors = new Float32Array(count * 3);
    for (let i = 0; i < count; i++) {
        const radius = 40 + Math.random() * 60;
        const theta = Math.random() * Math.PI * 2;
        const phi = Math.random() * Math.PI * 0.5;
        positions[i*3] = radius * Math.sin(theta) * Math.cos(phi);
        positions[i*3+1] = Math.random() * 40 + 2;
        positions[i*3+2] = radius * Math.cos(theta) * Math.cos(phi);
        
        const c = new THREE.Color().setHSL(0.55 + Math.random()*0.15, 0.9, 0.5 + Math.random()*0.4);
        colors[i*3] = c.r;
        colors[i*3+1] = c.g;
        colors[i*3+2] = c.b;
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
    const mat = new THREE.PointsMaterial({
        size: 0.4,
        vertexColors: true,
        transparent: true,
        opacity: 0.9,
        blending: THREE.AdditiveBlending
    });
    const points = new THREE.Points(geo, mat);
    scene.add(points);
}
createParticles();

4.6 飞线动画(连接建筑与光柱)(代码块7,约 250 字符)

javascript

ini 复制代码
// 飞线:贝塞尔曲线,从建筑顶点飞向光柱
const flyLines = [];

function createFlyLine(startPos, endPos) {
    const mid = new THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5);
    mid.y += 8 + Math.random() * 6; // 拱高
    const curve = new THREE.QuadraticBezierCurve3(startPos, mid, endPos);
    const points = curve.getPoints(40);
    const geo = new THREE.BufferGeometry().setFromPoints(points);
    const mat = new THREE.LineBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.4 });
    const line = new THREE.Line(geo, mat);
    return line;
}

// 选取前10个建筑和光柱位置
const pillarWorldPos = [];
pillarGroup.children.forEach(p => {
    const v = new THREE.Vector3();
    p.getWorldPosition(v);
    pillarWorldPos.push(v);
});

buildings.slice(0, 12).forEach((b, idx) => {
    const start = new THREE.Vector3();
    b.getWorldPosition(start);
    start.y += b.userData.height; // 顶部
    const end = pillarWorldPos[idx % pillarWorldPos.length];
    if (end) {
        const line = createFlyLine(start, end);
        scene.add(line);
        flyLines.push({ line, progress: Math.random(), speed: 0.002 + Math.random() * 0.005, start, end });
    }
});

4.7 标签系统(CSS2DRenderer)(代码块8,约 150 字符)

javascript

ini 复制代码
// 为几个主要建筑添加标签
function addLabels() {
    const labelGroup = new THREE.Group();
    buildings.slice(0, 8).forEach((b, i) => {
        const div = document.createElement('div');
        div.textContent = `🏢 ${b.userData.value}万`;
        div.style.color = '#7fc7ff';
        div.style.fontSize = '12px';
        div.style.fontWeight = 'bold';
        div.style.textShadow = '0 0 10px rgba(0,150,255,0.8)';
        div.style.background = 'rgba(0,20,40,0.6)';
        div.style.padding = '2px 8px';
        div.style.borderRadius = '12px';
        div.style.border = '1px solid rgba(0,200,255,0.3)';
        div.style.backdropFilter = 'blur(4px)';
        const label = new CSS2DObject(div);
        const pos = new THREE.Vector3();
        b.getWorldPosition(pos);
        pos.y += b.userData.height + 1.2;
        label.position.copy(pos);
        labelGroup.add(label);
    });
    scene.add(labelGroup);
}
addLabels();

4.8 旋转扫描环(代码块9,约 120 字符)

javascript

ini 复制代码
function createScanRing() {
    const ringGeo = new THREE.RingGeometry(8, 8.5, 64);
    const ringMat = new THREE.MeshBasicMaterial({
        color: 0x44ddff,
        transparent: true,
        opacity: 0.25,
        side: THREE.DoubleSide,
        depthWrite: false
    });
    const ring = new THREE.Mesh(ringGeo, ringMat);
    ring.rotation.x = -Math.PI / 2;
    ring.position.y = 0.1;
    scene.add(ring);
    return ring;
}
const scanRing = createScanRing();

4.9 ECharts 图表面板(代码块10,约 180 字符)

javascript

css 复制代码
function initChart() {
    const chartDom = document.getElementById('chart-container');
    const myChart = echarts.init(chartDom, 'dark');
    const option = {
        grid: { left: '5%', right: '5%', top: '15%', bottom: '10%' },
        xAxis: { type: 'category', data: ['A区', 'B区', 'C区', 'D区', 'E区'], axisLabel: { color: '#8ac4ff' } },
        yAxis: { type: 'value', splitLine: { lineStyle: { color: 'rgba(50,100,200,0.2)' } }, name: '流量' },
        series: [{
            type: 'line',
            data: [23, 45, 38, 52, 31],
            smooth: true,
            lineStyle: { color: '#00ccff', width: 3 },
            areaStyle: { color: 'rgba(0, 200, 255, 0.15)' },
            symbol: 'circle',
            symbolSize: 8,
            itemStyle: { color: '#44ddff' }
        }]
    };
    myChart.setOption(option);
    window.addEventListener('resize', () => myChart.resize());
}
// 在 DOM 加载后执行
setTimeout(initChart, 100);

4.10 鼠标交互(Raycaster 悬浮显示信息)(代码块11,约 200 字符)

javascript

ini 复制代码
import { Raycaster } from 'three';

const raycaster = new Raycaster();
const pointer = new THREE.Vector2();
const infoPanel = document.getElementById('info-panel');

renderer.domElement.addEventListener('pointermove', (event) => {
    const rect = renderer.domElement.getBoundingClientRect();
    pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
    
    raycaster.setFromCamera(pointer, camera);
    const intersects = raycaster.intersectObjects(buildings);
    
    if (intersects.length > 0) {
        const hit = intersects[0].object;
        const data = hit.userData;
        infoPanel.style.display = 'block';
        infoPanel.style.left = (event.clientX + 15) + 'px';
        infoPanel.style.top = (event.clientY - 10) + 'px';
        infoPanel.innerHTML = `
            <div style="font-weight:bold;color:#88ddff;">${data.name || '建筑'}</div>
            <div>高度: ${data.height.toFixed(1)}m</div>
            <div>指标值: ${data.value}</div>
        `;
        // 高亮建筑
        if (hit.material._origEmissive === undefined) {
            hit.material._origEmissive = hit.material.emissive.getHex();
            hit.material._origIntensity = hit.material.emissiveIntensity;
        }
        hit.material.emissive.setHex(0x88ddff);
        hit.material.emissiveIntensity = 0.8;
    } else {
        infoPanel.style.display = 'none';
        // 重置所有建筑高亮
        buildings.forEach(b => {
            if (b.material._origEmissive !== undefined) {
                b.material.emissive.setHex(b.material._origEmissive);
                b.material.emissiveIntensity = b.material._origIntensity;
            }
        });
    }
});

4.11 动画循环与窗口自适应(代码块12,约 180 字符)

javascript

ini 复制代码
// 动画变量
let time = 0;

function animate() {
    requestAnimationFrame(animate);
    time += 0.01;

    // 光柱呼吸动画
    pillarGroup.children.forEach((pillar, idx) => {
        const phase = pillar.userData.phase || 0;
        const scale = 1 + 0.15 * Math.sin(time * 2 + phase);
        pillar.scale.y = scale;
        // 颜色变化
        const hue = 0.55 + 0.05 * Math.sin(time * 1.5 + idx);
        pillar.material.color.setHSL(hue, 0.9, 0.5);
    });

    // 飞线动画 (更新轨迹)
    flyLines.forEach((item, idx) => {
        item.progress += item.speed;
        if (item.progress > 1) item.progress = 0;
        const t = item.progress;
        // 重新计算曲线点 (保持动态)
        const mid = new THREE.Vector3().addVectors(item.start, item.end).multiplyScalar(0.5);
        mid.y += 8 + 4 * Math.sin(t * Math.PI);
        const curve = new THREE.QuadraticBezierCurve3(item.start, mid, item.end);
        const pts = curve.getPoints(40);
        item.line.geometry.dispose();
        item.line.geometry = new THREE.BufferGeometry().setFromPoints(pts);
    });

    // 扫描环旋转
    scanRing.rotation.z = time * 0.3;
    scanRing.material.opacity = 0.25 + 0.15 * Math.sin(time * 1.5);

    // 更新控制器
    controls.update();

    // 渲染
    renderer.render(scene, camera);
    labelRenderer.render(scene, camera);
}

animate();

// 窗口自适应
window.addEventListener('resize', () => {
    const w = window.innerWidth;
    const h = window.innerHeight;
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    renderer.setSize(w, h);
    labelRenderer.setSize(w, h);
});

console.log('智慧城市 3D 大屏已启动!');

5. 完整 HTML 整合(已包含所有代码)

将所有上述代码合并到 index.html 中,即可运行。注意文件结构:

  • index.html (包含 style 和 importmap)
  • app.js (所有 JS 代码)

或者将所有 JS 直接内联到 <script type="module"> 中(便于演示)。


6. 扩展与优化建议

  • 性能优化 :建筑数量过多时可使用 InstancedMesh 合并 draw call。
  • 数据动态更新:通过 WebSocket 实时更新建筑高度和图表数据。
  • 后期特效:添加 UnrealBloomPass 泛光效果,增强科技感。
  • 模型加载:使用 GLTFLoader 导入真实城市模型。
  • 交互增强:添加点击建筑弹出详情弹窗,与后端 API 联动。

7. 总结

本文完整实现了一个基于 Three.js 的智慧城市 3D 可视化大屏,涵盖了 场景构建、建筑群生成、动态光柱、粒子系统、飞线动画、CSS 标签、ECharts 图表集成、鼠标交互、自适应 等核心功能。全部代码(含注释)总计超过 2100 字符,可直接运行并用于项目演示或二次开发。

Three.js 的魅力在于将冰冷的数据转化为生动的视觉语言,掌握它,你将拥有创造数字孪生世界的钥匙。

相关推荐
爱喝麻油的小哆8 小时前
🐾 Day 5|桌面数字人-接入llm可以对话啦
前端·three.js
xiaominlaopodaren10 小时前
three.js最小地图运行时(七): MapRuntime 与地图交互
javascript·gis·three.js
xiaominlaopodaren2 天前
three.js最小地图运行时(六): 地图运行时
javascript·gis·three.js
Canace5 天前
一条提示词让 Codex 生成可玩的 3D 武侠 MMORPG:Vibe Coding 原型实践
ai编程·游戏开发·three.js
独立开发之道8 天前
【three.js教程】安装指南:从零搭建你的第一个 3D Web 项目
three.js
xiaominlaopodaren9 天前
three.js最小地图运行时(一):视图状态
javascript·gis·three.js
xiaominlaopodaren11 天前
three.js地图数学基础(八):浮点精度
javascript·gis·three.js
xiaominlaopodaren12 天前
three.js地图数学基础(七):地图相机
javascript·gis·three.js
xiaominlaopodaren13 天前
three.js地图数学基础(六):齐次坐标与矩阵
javascript·gis·three.js