后端开发用threejs实现了中国航线一张图?

7,711 阅读12分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情

作为后端的我,最近着实不知道写些哪方面的文章了。恰好公司有GIS相关的业务,闲来无事,研究一下threejs。所以本篇文章将是我使用threejs开发的第一个小项目,有很多不足之处,大家可以多多指教。

源码地址:gitee.com/wei_rong_xi…

建议PC端观看,在手机端省份显示没做适配,无法展示;且流动线条的样式存在问题。

概述

当前数字城市、数字农业、数字交通等等概念盛行,在最近的世界5G大会上,我们看到,移动、联通、电信等运营商,通过一张图的形式,去展示他们在不领域业的实力。我们能够从这样的一张图上直观的看到各种数据和指标,且生动、形象,具有极强的互动性,给用户最直接的感触。

作为程序员来说,我们想要这样一张图的效果,有很多种方式,而我选择使用threejs去做一个尝试,虽然我是后端程序员,但是对于js代码还是较好理解的。相比于unity3d等等一系列的模型工具,threejs更容易被我接受,接下来,咱们一起来尝试使用threejs完成第一个项目:中国地图

创作不易,如需转载,请标明作者【我犟不过你】。

准备工作

数据准备

第一步,我们需要准备中国地图的数据,无论是常见的Echarts还是其他报表,常见的方式,都是通过json数据的形式去绘制地图的,threejs也不例外。

我们使用阿里云的数据可视平台:DataV.GeoAtlas,获取GeoJSON数据。

GeoJSON是包含地图各种坐标的一组json数据,从国家的省份到县的地理经纬度坐标。我们只需要解析渲染这些坐标,就能够完成一个地图信息的展示。

环境搭建

本文采用vue3 + vite + threejs的形式去展示地图。

关于如何创创建项目请参考文章:# 后端只能写后端?vue + vite环境搭建你也必须要会呀!!

关于路由的引入和配置本文不介绍了。

引入相关依赖

引入threejs:

npm install three 

引入orbitcontrols:

npm install three-orbit-controls 

这里需要注意的一点是threejs的版本问题,查看package.json,我这里手动修改了版本为:

"three": "^0.120.1",

原因是,默认安装的最新版本,api变化很大,导致我这个新手使用起来很懵,且我查看的文档对应的不是最新版本,所以此次干脆使用旧版本去尝试一下好了。

引入dagre-d3,用于将geojson的经纬度转换成可现实的坐标:

npm install dagre-d3 

引入压缩插件,防止静态文件过大,导致加载慢:

npm i vite-plugin-compression -D 

vite.config.js

vite.config.js文件内容如下:

import {defineConfig, loadEnv} from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path' // 需安装此模块
import viteCompression from 'vite-plugin-compression'

export default defineConfig(({mode, command}) => {
    const env = loadEnv(mode, process.cwd());
    return {
        plugins: [
            vue(),
            viteCompression({
                verbose: true,
                disable: false,
                threshold: 10240,
                algorithm: 'gzip',
                ext: '.gz',
            })
        ],
        resolve: {
            alias: {
                '@': path.resolve(__dirname, 'src')
            }
        },
        define: {
            'process.env': {}
        },
        server: {
            host: 'localhost',
            port: '8080',
            strictPort: false,//设为true时端口被占用则直接退出,不会尝试下一个可用端口
            cors: true,//为开发服务器配置CORS, 默认启用并允许任何源
            open: true,//服务启动时自动在浏览器中打开应用
            hmr: false,//禁用或配置 HMR 连接
            proxy: {
                [env.VITE_BASE_API]: {
                    target: env.VITE_BASE_URL,//实际请求地址
                    changeOrigin: true,
                    ws: true,// websocket支持
                    rewrite: (path) => path.replace(env.VITE_BASE_API, '')//替换实际请求后台地址
                }
            },
            https: false
        }
    }
})

开始编码

初始化环境

在学习threejs时,我们知道有几个对象,是必须要按照固有模式进行初始化的,当你了解过其他的3D工具,会发现他们都有些共同的组件,比如灯光、相机、材质等等。

初始化场景 initScene()

定义一个全局的场景scene,通过initScene()方法进行初始化:

/**
 * 创建场景对象Scene
 */
function initScene() {
  scene = new THREE.Scene();
  /**
   * 光源设置
   */
      //环境光
  let ambient = new THREE.AmbientLight(0xffffff);

  scene.add(ambient);
  // 辅助坐标系  参数250表示坐标系大小,可以根据场景大小去设置
  // let axesHelper = new THREE.AxesHelper(100);
  // scene.add(axesHelper);
}

如上所示,最后注释的两行是辅助坐标系,能够帮助我们在开发时更好的把控空间的位置:

image.png

初始化相机 initCamera()

相机是让我们以第三人称的视角,去观看我们绘制的图形,当我们移动相机的角度,可以查看到3D图形在不同角度的展示内容。

/**
 * 初始化相机
 */
function initCamera() {
  /**
   * 相机设置
   */
      //窗口宽度
  let width = window.innerWidth;
  //窗口高度
  let height = window.innerHeight;
  //窗口宽高比
  let k = width / height;
  //三维场景显示范围控制系数,系数越大,显示的范围越大
  let s = 70;
  //创建相机对象
  camera = new THREE.OrthographicCamera(-s * k, s * k, s, -s, 1, 1000);
  //设置相机位置
  camera.position.set(0, -100, 300);
  //设置相机方向(指向的场景对象)
  camera.lookAt(scene.position);
}

根据下面的图,简单理解下:

未命名文件 (2).png

初始换渲染器 initRenderer()

渲染器用来渲染我们绘制的图像,其中可以指定渲染区域,背景等等。

/**
 * 创建渲染器对象
 */
function initRenderer(width, height) {
  renderer = new THREE.WebGLRenderer();
  //设置渲染区域尺寸
  renderer.setSize(width, height);
  //设置背景颜色
  renderer.setClearColor('#343333', 1);
  //body元素中插入canvas对象
  document.body.appendChild(renderer.domElement);
  // 鼠标,键盘控制
  //创建控件对象
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true; // 启用阻尼(惯性),这将给控制器带来重量感,如果该值被启用,必须在动画循环里调用.update()
  controls.dampingFactor = 0.05;
  controls.update();
  //监听鼠标、键盘事件
  controls.addEventListener('change', render);
}

如上所示,我们创建了对象controls,用来控制鼠标键盘对对象的控制操作,包括移动,缩放,旋转等。

  • 鼠标左键:旋转
  • 鼠标右键:平移
  • 鼠标滚轴:缩放、扩展
  • 键盘方向:平移

效果如图所示:

GIF 2022-8-15 14-34-07.gif

渲染器想要渲染场景,必须要进行指定api的调用,如下所示:

renderer.render(scene, camera);

如上所示的参数是场景scene和相机camera。

通常会定义个render方法,专门用来执行渲染操作:


/**
 * 执行渲染操作
 */
function render() {
  renderer.render(scene, camera);
}

创建图形对象

在每个3d图像实现的过程当中,都需要创建:

  • 几何图形:Geometry
  • 材质:Material

有个几何和材质,记下来需要创建:

  • 网格:Mesh
  • 线:Line

在完成上面的创建后,我们需要将网格或者线添加到场景当中,分则无法渲染:

scene.add(mesh);
scene.add(line);

也可以创建组Group,然后添加组到场景:

let meshGroup = new THREE.Group();
meshGroup.add(mesh,line);

scene.add(meshGroup);

初始化地图 initMap()

首先我们需要做的就是在页面展示出地图,定义一个初始化地图的方法:

import chinaJson from "../../assets/china.json"

/**
 * 初始化地图信息
 * @param chinaJson
 */
function initMap(chinaJson) {
  // 创见一个3d对象
  map = new THREE.Object3D();

  // 遍历地图json
  chinaJson.features.forEach(elem => {
    // 创建省份3d对象
    const province = new THREE.Object3D();
    // 坐标数组
    const coordinates = elem.geometry.coordinates;
    // 循环坐标数组
    // 将geo的属性放到省份模型中
    coordinates.forEach(multiPolygon => {
      // 处理内蒙古坐标数组的特殊情况
      if (elem.geometry.type === 'Polygon') {
        multiPolygon = [multiPolygon];
      }
      multiPolygon.forEach(polygon => {
        const shape = new THREE.Shape();
        // 创建线材质,白色
        const lineMaterial = new THREE.LineBasicMaterial({color: 'white'});

        // 创建Geometry
        const lineGeometry = new THREE.BufferGeometry()
        // 创建位置数组
        const positionArray = new Array();
        for (let i = 0; i < polygon.length; i++) {
          const [x, y] = projection(polygon[i]);
          if (i === 0) {
            shape.moveTo(x, -y);
          }
          shape.lineTo(x, -y);
          // 填充位置数组,指定3d模型高度
          positionArray.push(new THREE.Vector3(x, -y, 10.00))
        }
        // 设置位置数组到lineGeometry
        lineGeometry.setFromPoints(positionArray)
        // 指定几何属性
        const extrudeSettings = {
          depth: 10,// 总体高度
          bevelEnabled: false
        };
        // 创建一个3d几何图形
        const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings)
        // 创建材质1 指定颜色、透明度,几何表面
        const material = new THREE.MeshBasicMaterial({color: '#5dd9ec', transparent: true, opacity: 0.6})
        // 创建材质2 指定颜色、透明度,几何内部
        const material1 = new THREE.MeshBasicMaterial({color: '#5dd9ec', transparent: true, opacity: 0.6})
        // 创建网格
        const mesh = new THREE.Mesh(geometry, [material, material1])
        // 创建线
        const line = new THREE.Line(lineGeometry, lineMaterial)
        // 添加到3d对象中
        province.add(mesh)
        province.add(line)
      })
    })
    province.properties = elem.properties;
    if (elem.properties.contorid) {
      const [x, y] = projection(elem.properties.contorid);
      province.properties._centroid = [x, y];
    }
    // 添加省份到map
    map.add(province);
  })
  // 添加省份map到场景
  scene.add(map);
}

如上所示,有几个关键点:

  • 引入地图json

    import chinaJson from "../../assets/china.json"
    
  • 遍历json,内蒙古异常

参考文章:# threejs使用geojson绘制中国地图的小坑【内蒙古丢啦?】

  • 墨卡托投影转换

    前面我们在搭建环境引入的d3,就是在此处使用,为了将geojson数据的经纬度转换成坐标位置。

    const projection = d3.geoMercator().center([106.412318, 38.909843]).translate([0, 0])
    

    通过如下方式使用:

    const [x, y] = projection(polygon[i]);
    

    得到的x、y是转换后得到的坐标,需要注意的是,y是相反的,所以使用时是-y

效果展示:

image.png

添加省份名称

我们想要在鼠标浮动到地图上时,展示相应的省份名称,同时改变该省份的颜色。

设置div

首先设置一个div,用来展示省份的名称:

<div ref="provinceName" id="provinceName">
</div>

css如下:

#provinceName {
  position: absolute;
  z-index: 2;
  background: white;
  padding: 10px;
  border-radius: 2px;
  visibility: hidden;
}

动画展示

需要用到以下的组件Raycaster,同时增加鼠标监控事件:

/**
 * 设置Raycaster
 */
function setRaycaster() {
  // 初始化Raycaster
  raycaster = new THREE.Raycaster()
  // 初始化鼠标事件
  mouse = new THREE.Vector2()
  const onMouseMove = (event) => {
    // x 和 y 方向的取值范围是 (-1 to +1)
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
    // 更改div位置
    provinceName.value.style.left = event.clientX + 2 + 'px'
    provinceName.value.style.top = event.clientY + 2 + 'px'
    // 设置首次访问为false
    firstIn.value = false;
  }
  // 添加鼠标移动监听事件
  window.addEventListener('mousemove', onMouseMove, false)
}

以上步骤还是不够的,我们值是监控了鼠标的事件,还需要在监控后对地图的颜色和名称div进行处理和渲染render,所以我们定义一个动画函数:

/**
 * 鼠标移动后的变化
 */
function animate() {
  requestAnimationFrame(animate.bind(this))
  // 通过摄像机和鼠标位置更新射线
  raycaster.setFromCamera(mouse, camera)
  // 算出射线 与当场景相交的对象有那些
  const intersects = raycaster.intersectObjects(
      scene.children,
      true
  )
  // 恢复地图原本颜色
  if (selected) {
    selected.object.material[0].color.set('#5dd9ec')
    selected.object.material[1].color.set('#5dd9ec')
    provinceName.value.style.visibility = 'hidden'
  }
  selected = null
  selected = intersects.find(
      (item) => item.object.material && item.object.material.length === 2
  )
  if (selected) {
    if (!firstIn.value) {
      selected.object.material[0].color.set('#a1f5c4')
      selected.object.material[1].color.set('#a1f5c4')
      // 展示省份名称
      showTip()
    }
  }
  render()
}

效果如下:

GIF 2022-8-15 14-01-18.gif

模拟航线

只是一个地图似乎不够丰富,我们简单模拟一下飞机的航线,让地图看起来精彩一点。创作不易,如需转载,请标明作者【我犟不过你】。

初始化航线

定义指定的航线线路,均指向北京,这里数组写死,实际可以后台获取:

const planeLine = [
  [126.642464, 45.756967], [116.405285, 39.904989],
  [121.472644, 31.231706], [116.405285, 39.904989],
  [114.173355, 22.320048], [116.405285, 39.904989],
  [123.429096, 41.796767], [116.405285, 39.904989],

  [87.617733, 43.792818], [116.405285, 39.904989],
  [91.132212, 29.660361], [116.405285, 39.904989],
  [101.778916, 36.623178], [116.405285, 39.904989],
  [102.712251, 25.040609], [116.405285, 39.904989],
  [103.823557, 36.058039], [116.405285, 39.904989],
  [111.670801, 40.818311], [116.405285, 39.904989]
]

定义函数 initPlaneLine():

/**
 * 初始化飞机航线
 */
function initPlaneLine(planeLine) {
  // 遍历航线数组
  for (let i = 0; i < planeLine.length; i += 2) {
    // 点1坐标
    const [x, y] = projection([planeLine[i][0], planeLine[i][1]]); 
    const point1 = [x, -y, 10]
    // 点2坐标
    const [n, m] = projection([planeLine[i + 1][0], planeLine[i + 1][1]])
    const point2 = [n, -m, 10];
    // 航线中间点坐标
    const controlPoint = [(x + n) / 2, (-y + -n) / 2, 20]; 

    // 创建三维二次贝塞尔曲线
    let curve = new THREE.QuadraticBezierCurve3(
        new THREE.Vector3(point1[0], point1[1], point1[2]),
        new THREE.Vector3(controlPoint[0], controlPoint[1], controlPoint[2]),
        new THREE.Vector3(point2[0], point2[1], point2[2])
    );
    // 曲线的分段数量
    divisions = 10;
    // 返回 分段数量 + 1 个点,例如这里的points.length就为31
    points = curve.getPoints(divisions); 
    geometry = new THREE.Geometry();
    geometry.vertices = points;
    // 设置顶点 colors 数组,与顶点数量和顺序保持一致。
    geometry.colors = new Array(points.length).fill(
        new THREE.Color('#333300')
    );
    // 生成材质
    let material = new THREE.LineBasicMaterial({
      vertexColors: THREE.VertexColors, // 顶点着色
      transparent: true, // 定义此材质是否透明
      side: THREE.DoubleSide,
      opacity: 0.8,
      linewidth: 10
    });
    //航线网格
    const mesh = new THREE.Line(geometry, material);
    scene.add(mesh);
    // 添加几何到数组,留待后面使用
    geometryGroup.push(geometry);
  }
}

效果预览:

image.png

美化航线

下面我们让航线变成动态,在前面提到的动画函数animate()中进行处理,我们创建一个planeAnimate()函数,在animate()函数进行调用:

/**
 * 航线动画
 */
function planeAnimate() {
  // controls.enableDamping设为true时(启用阻尼),必须在动画循环里调用.update()
  controls.update();
  // 时间间隔
  let now = new Date().getTime();
  if (now - timestamp > 50) {
    // 遍历前面组装的几何数组
    geometryGroup.forEach(g => {
      g.colors = new Array(divisions + 1)
          // 设置分段的背景色
          .fill(new THREE.Color('#06ee12'))
          .map((color, index) => {
            // 如果分段的索引 和 颜色索引相同,赋予指定颜色
            if (index === colorIndex) {
              return new THREE.Color('#fd8802');
            }
            return color;
          });
      // 如果geometry.colors数据发生变化,colorsNeedUpdate值需要被设置为true
      g.colorsNeedUpdate = true;
    })

    timestamp = now;
    // 颜色索引自增,默认0
    colorIndex++;
    // 大于分段数,重置
    if (colorIndex > divisions) {
      colorIndex = 0;
    }
  }
}

效果预览:

GIF 2022-8-15 13-55-59.gif

增加航线起始点

上述的航线不是很优美,看不到起始点,所以我们现在将起始城市的坐标进行优化,使用倒立的锥形和圆形线圈进行装饰。

创建函数initCity():

/**
 * 初始化城市
 */
function initCity(planeLine) {
  let meshGroup = new THREE.Group();
  //关键帧时间数组,离散的时间点序列
  let times = [0, 10]; 
  // 动画数组
  let playArr = [];
  // 根据航线数组生成锥形和圆圈
  for (let i = 0; i < planeLine.length; i++) {
    // 生成锥形
    let plane = new THREE.ConeGeometry(1, 1.5);
    let material = new THREE.MeshPhongMaterial({
      transparent: true,
      specular: '#2cfd3f',
      color: '#2cfd3f',
      shininess: 12
    });
    let mesh = new THREE.Mesh(plane, material);
    // 旋转角度,使锥形的尖朝向X轴和Y轴所在的面
    mesh.rotateX(Math.PI / -2)
    // 指定每个锥形的名称
    mesh.name = "Cone" + i;
    // 每个城市的坐标,作为锥形的坐标
    const [x, y] = projection([planeLine[i][0], planeLine[i][1]]); // 点1坐标
    // 设置锥形的位置
    mesh.position.set(x, -y, 13)
    // 创建圆圈几何
    let geometry = new THREE.Geometry(); //声明一个几何体对象Geometry
    let R = 1; //圆弧半径
    let N = 150; //分段数量
    // 批量生成圆弧上的顶点数据
    for (let i = 0; i < N; i++) {
      let angle = 2 * Math.PI / N * i;
      let x1 = R * Math.sin(angle);
      let y1 = R * Math.cos(angle);
      geometry.vertices.push(new THREE.Vector3(x1, y1, 0));
    }
    // 插入最后一个点,line渲染模式下,产生闭合效果
    geometry.vertices.push(geometry.vertices[0])
    //材质对象
    let materialC = new THREE.LineBasicMaterial({
      color: '#2cfd3f'
    });
    //创建线型圆圈
    let line = new THREE.Line(geometry, materialC);
    // 对圆圈命名
    line.name = 'CityLine' + i;
    // 指定圆圈位置
    line.position.set(x, -y, 10)
    //与时间点对应的值组成的数组
    let values = [x, -y, 13, x, -y, 11]; 
    // 创建位置关键帧对象:0时刻对应位置0, 0, 0   10时刻对应位置150, 0, 0
    let posTrack = new THREE.KeyframeTrack('Cone' + i + '.position', times, values);
    // 创建名为Sphere对象的关键帧数据  从0~20时间段,尺寸scale缩放3倍
    let scaleTrack = new THREE.KeyframeTrack('CityLine' + i + '.scale', [0, 20], [1, 1, 1, 3, 3, 3]);
    playArr.push(posTrack, scaleTrack)
    meshGroup.add(line, mesh)
  }
  scene.add(meshGroup)


  // duration决定了默认的播放时间,一般取所有帧动画的最大时间
  // duration偏小,帧动画数据无法播放完,偏大,播放完帧动画会继续空播放
  let duration = 20;
  // 多个帧动画作为元素创建一个剪辑clip对象,命名"default",持续时间20
  let clip = new THREE.AnimationClip("default", duration, playArr);

  /**
   * 播放编辑好的关键帧数据
   */
  // group作为混合器的参数,可以播放group中所有子对象的帧动画
  mixer = new THREE.AnimationMixer(meshGroup);
  // 剪辑clip作为参数,通过混合器clipAction方法返回一个操作对象AnimationAction
  let AnimationAction = mixer.clipAction(clip);
  //通过操作Action设置播放方式
  AnimationAction.timeScale = 20;//默认1,可以调节播放速度
  // AnimationAction.loop = THREE.LoopOnce; //不循环播放
  AnimationAction.play();//开始播放
}

完成上述操作后,在页面还看不到动态效果:

GIF 2022-8-15 14-14-36.gif

注意我们在上面的方法定义了mixer,通过它实现组对象的动画播放:

image.png

所以我们只需要在渲染函数内去更新这个混合器的每贞的时间就可以了:

/**
 * 执行渲染操作
 */
function render() {
  renderer.render(scene, camera);
  // 更新混合器相关的时间
  mixer.update(clock.getDelta());
}

效果如下:

GIF 2022-8-15 14-21-28.gif

增加星空背景

其实到此位置,整个地图家航线的展示就完成了,但是背景略显空旷,所以我们增加一个星空的背景,定义一个函数:initStar()

function initStar() {
  let geometry = new THREE.Geometry();
  //粒子材质
  let material = new THREE.ParticleBasicMaterial({
    size: 2,
    vertexColors: true
  });
  let n = 1200;
  for (let i = 0; i < 3000; i++) {
    let particle = new THREE.Vector3(
        (Math.random() - 0.5) * n,
        (Math.random() - 0.5) * n,
        (Math.random() - 0.5) * n
    );
    //随机位置
    geometry.vertices.push(particle);
    // 设置随机颜色
    let color = Math.random();
    geometry.colors.push(new THREE.Color(color, color, color * 2.0));
  }
  star = new THREE.ParticleSystem(geometry, material);
  scene.add(star)
}

此时我们看到的星星效果是静态的,因为在动画行数animate()当中,没有去修改星星的位置。所以我们只需要在其中增加以下内容就可以让星星动起来了:

star.rotation.x += 0.00001;
star.rotation.y += 0.00001;
star.rotation.z += 0.00001;

最终效果如下所示:

GIF 2022-8-15 13-59-29.gif

总结

一个完整的带有星空背景的中国城市航线图就完成了。对于一个后端人员来说,制作起来还是比较简单的,因为threejs的封装做的不错,api使用起来较为简单,只需要我们稍微具有以下的能力即可:

  • vue使用(别的前端架子也可以)
  • 阅读文档的能力
  • 动手能力
  • 熟悉js的基本操作
  • 有一些空间感

有上面的这些能力,就可以快速的完成简单threeejs项目的研发了。


创作不易,如需转载,请标明作者【我犟不过你】。