Three.js进阶:打造可交互的3D苏州地图,悬浮高亮与飞线动画实战

0 阅读6分钟

在上一篇文章中,我们成功将苏州各区县的GeoJSON数据转换为了带有立体感和边缘高亮的3D地图(用 Three.js 和 D3 在 Vue 中打造 3D 苏州地图)。但一个静态的地图显然不能满足我们对交互体验的追求。今天,我们将在这个基础上进行一系列进阶优化,让地图“活”起来:

  • 悬浮高亮:鼠标滑过区域时,区域变为青色并显示名称浮窗
  • 飞线动画:从市中心到各区县绘制流动的曲线,并有红点沿曲线移动
  • 圆环扩散:飞线起点和终点呈现呼吸般的圆环动画

这些效果将大幅提升地图的视觉吸引力和信息传达能力。本文将从原理出发,逐步讲解如何用Three.js和D3实现这些交互与动画。

本文代码基于Vue 3 + Three.js + d3-geo

动画.gif

原理概览

在动手之前,我们先理解几个核心概念:

  • 射线检测(Raycaster) :Three.js提供的用于检测鼠标位置与3D物体相交的工具。我们将鼠标坐标转换为射线,然后与存储的所有区域网格进行相交测试,从而实现悬浮高亮。
  • CSS2D渲染器(CSS2DRenderer) :虽然本例中未使用,但它是将HTML元素作为标签附着在3D物体上的常用方法。我们这里采用简单的绝对定位div来显示区域名称,更轻量。
  • 贝塞尔曲线(QuadraticBezierCurve3) :用于生成平滑的飞线路径。通过起点、终点和一个控制点(抬高Z轴),创造出拱形曲线。
  • 动画驱动:利用requestAnimationFrame在每一帧更新物体的位置、缩放、透明度等属性。为了提高性能,我们采用时间戳控制更新频率,避免每帧都做大量计算。

技术实现步骤

1. 环境准备与场景搭建

基础场景的搭建与前文一致,包括相机、渲染器、轨道控制器等。但为了悬浮效果,我们需要额外引入射线检测器,并存储所有可交互的区域网格:

javascript

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const districtMeshes = []; // 存储所有区域mesh,用于点击检测

在创建挤出网格时,我们将每个mesh推入districtMeshes,并在userData中保存区域名称和原始颜色,便于后续恢复:

javascript

mesh.userData = { 
    districtName: districtName,
    originalColor: color,
    originalOpacity: 0.5
};
districtMeshes.push(mesh);

2. 区域悬浮高亮与信息展示

2.1 监听鼠标移动

我们在渲染器的dom元素上绑定mousemove事件,将鼠标坐标归一化后交给射线检测器:

javascript

renderer.domElement.addEventListener('mousemove', onMouseMove);

function onMouseMove(event) {
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
    
    raycaster.setFromCamera(mouse, camera);
    const intersects = raycaster.intersectObjects(districtMeshes);
    
    // 处理悬浮逻辑...
}

2.2 处理相交结果

intersects长度大于0时,表示鼠标悬浮在某区域上。我们首先恢复上一个高亮区域的颜色,然后将当前相交的网格颜色改为青色(0x00ffff),并提高透明度。同时,调用自定义的showDistrictInfo显示区域名称浮窗。

javascript

if (intersects.length > 0) {
    const hoveredMesh = intersects[0].object;
    // 如果有已选中的网格且不是当前网格,恢复原色
    if(selectedMesh && selectedMesh !== hoveredMesh){
        selectedMesh.material.color.setHex(selectedMesh.userData.originalColor);
        selectedMesh.material.opacity = selectedMesh.userData.originalOpacity;
    }
    hoveredMesh.material.color.setHex(0x00ffff);
    hoveredMesh.material.opacity = 0.7;
    selectedMesh = hoveredMesh;
    showDistrictInfo(hoveredMesh.userData.districtName, event.clientX, event.clientY);
} else {
    // 没有相交时,恢复所有区域颜色
    districtMeshes.forEach(mesh => {
        mesh.material.color.setHex(mesh.userData.originalColor);
        mesh.material.opacity = mesh.userData.originalOpacity;
    });
    selectedMesh = null;
    hideDistrictInfo();
}

2.3 信息浮窗

我们在模板中定义了一个隐藏的div,通过添加/移除visible类来控制显示,并动态设置其位置:

html

<div id="city-info" class="city-info"></div>

javascript

function showDistrictInfo(name, x, y) {
    const infoDiv = document.getElementById('city-info');
    infoDiv.innerHTML = `<strong>${name}</strong>`;
    infoDiv.style.left = (x + 15) + 'px';
    infoDiv.style.top = (y + 15) + 'px';
    infoDiv.classList.add('visible');
}

function hideDistrictInfo() {
    document.getElementById('city-info').classList.remove('visible');
}

3. 飞线动画

飞线由三部分组成:起点/终点的圆环动画、沿曲线移动的红点、连接两点的曲线本身。

3.1 创建曲线

我们使用二次贝塞尔曲线QuadraticBezierCurve3,起点和终点位于地图表面(Z=10.1),控制点抬高到Z=40,形成拱形:

javascript

function lineconnect(startPos, endPos) {
    const [x,y,z] = [...startPos, 10.1];
    const [x1,y1,z1] = [...endPos, 10.1];
    const curve = new THREE.QuadraticBezierCurve3(
        new THREE.Vector3(x, y, z),
        new THREE.Vector3((x + x1)/2, (y + y1)/2, 40),
        new THREE.Vector3(x1, y1, z1)
    );
    
    // 添加起点和终点的圆环
    spotCircle(startPos);
    spotCircle(endPos);
    
    // 添加移动点
    moveSpot(curve);
    
    // 绘制曲线
    const points = curve.getPoints(50);
    const geometry = new THREE.BufferGeometry().setFromPoints(points);
    const material = new THREE.LineBasicMaterial({ color: 0x00FF00 });
    const line = new THREE.Line(geometry, material);
    return line;
}

3.2 圆环扩散动画

在起点和终点位置,我们创建一个圆环和一个实心圆,并利用userData存储动画状态(缩放、透明度、速度)。所有圆环对象被推入ringAnimations数组,在动画循环中统一更新:

javascript

function spotCircle(pos) {
    const [x,y,z] = [...pos, 10.1];
    // 实心圆
    const geometry = new THREE.CircleGeometry(0.5, 32);
    const material = new THREE.MeshBasicMaterial({ color: 0x00FF00, side: THREE.DoubleSide });
    const circle = new THREE.Mesh(geometry, material);
    circle.position.set(x, y, z);
    scene.add(circle);

    // 圆环
    const geometry2 = new THREE.RingGeometry(0.7, 1.2, 32);
    const material2 = new THREE.MeshBasicMaterial({ color: 0x00FF00, side: THREE.DoubleSide });
    const ring = new THREE.Mesh(geometry2, material2);
    ring.position.set(x, y, z);
    ring.userData = { 
        type: 'ringAnimation', 
        scale: 1, 
        opacity: 1,
        speed: 0.02 + Math.random() * 0.02
    };
    ringAnimations.push(ring);
    scene.add(ring);
}

3.3 移动的红点

创建一个球体,挂载到曲线上,并在每一帧根据duration获取曲线上的点更新位置:

javascript

function moveSpot(curve) {
    const sphere = new THREE.Mesh(
        new THREE.SphereGeometry(0.8, 8, 8),
        new THREE.MeshBasicMaterial({ color: 0xFF0000 })
    );
    sphere.curve = curve;
    sphere.duration = 0;
    moveSpots.push(sphere);
    scene.add(sphere);
}

4. 动画性能优化

为了避免每帧更新所有动画造成的性能损耗,我们采取两种策略:

  • 控制圆环更新频率:使用时间戳判断,每50ms更新一次圆环的缩放和透明度,避免高频计算。
  • 使用数组管理动画对象:不遍历整个场景,只遍历ringAnimationsmoveSpots数组,提高查找效率。

javascript

let lastRingUpdate = 0;
const RING_UPDATE_INTERVAL = 50;

function animate(timestamp) {
    requestAnimationFrame(animate);
    
    // 圆环动画(降频)
    if (timestamp - lastRingUpdate > RING_UPDATE_INTERVAL) {
        lastRingUpdate = timestamp;
        ringAnimations.forEach(obj => {
            obj.userData.scale += obj.userData.speed;
            obj.scale.set(obj.userData.scale, obj.userData.scale, 1);
            obj.userData.opacity -= obj.userData.speed * 0.5;
            if (obj.userData.opacity <= 0) {
                obj.userData.opacity = 1;
                obj.userData.scale = 1;
            }
            obj.material.opacity = obj.userData.opacity;
        });
    }
    
    // 飞线移动点动画(每帧更新,保证流畅)
    moveSpots.forEach(spot => {
        spot.duration += 0.006;
        const pos = spot.curve.getPointAt(spot.duration % 1);
        spot.position.copy(pos);
    });
    
    renderer.render(scene, camera);
}

5. 添加飞线实例

init函数加载完GeoJSON后,我们调用addLine函数,为各区县添加飞线(以市中心为起点):

javascript

function addLine() {
    const center = projection([120.619585, 31.299379]); // 市中心坐标
    const targets = [
        { name: '虎丘区', coords: [120.566833, 31.294845] },
        { name: '吴中区', coords: [120.624621, 31.270839] },
        // ... 其他区县
    ];
    targets.forEach(target => {
        const line = lineconnect(center, projection(target.coords));
        scene.add(line);
    });
}

效果演示

运行项目后,你将看到:

  • 鼠标滑过区域时,该区域高亮为青色,并弹出区域名称浮窗。
  • 从市中心放射出多条绿色曲线,连接至各区县。
  • 每条曲线的起点和终点有脉动扩散的圆环。
  • 一个红点沿着曲线缓缓移动,形成流动感。

优化点

  1. 文本标签:可以使用CSS2DRenderer为每个区域添加永久性的名称标签,方便识别。
  2. 颜色配置:为不同区域分配不同的颜色(如districtColors对象所示),使视觉更丰富。
  3. 点击事件:可以扩展为点击区域跳转到详情页面或显示更多数据。
  4. 性能进一步优化:对于复杂的飞线,可以使用InstancedMesh来批量绘制移动点,减少draw call。