【保姆进阶级】Three.js做一个酷炫的城市展示可视化大屏

本文介绍如何利用Three.js和d3.js创建一个酷炫的城市展示可视化大屏,涉及地图投影、CSS2DRenderer、tween.js动画、地图边缘流光效果等技术。文章还分享了代码实现细节,包括地图模型、标签、数据模型的添加以及交互功能的实现。
该文章已生成可运行项目,

hi,大家好,我是ethan。

想记录博客很久了,一直懒得开个头,以前写过全栈、java、写过python、写过前端,写过安全、写过互联网,但是我还是更喜欢前端可视化,平时也喜欢研究一下可视化的技术,也是从d3、gis、threejs、echarts、hicharts、cesium一步步淌过来的,可视化方向的路还有很长,我觉得一些shader实在是好难....

web3.0盛行,元宇宙也是跟前端密切相关的,也想学习一下unity、three.ar.js之类的,有想法的小伙伴可以一起沟通一下~

言归正传,最近呢在做一个可视化大屏,当然要炫,毕竟领导喜欢,废话不多说,先上预览:

bb185a2e-b902-48eb-91a6-5ea79eaf53c9

分解代码前,我们先介绍一些这里面有几个技术点:


1、d3.js通过投影把地图数据的json映射到3维空间中,城市地图的json下载我就不多讲了,网上有很多教程,换成自己所需的城市就行;

2、地图上展示的数据展示的label,一开始用的sprite小精灵模型做的,但是会失真不清楚,后来换成了CSS2DRenderer这种方式,就相当于把html渲染到3维空间里,屡试不爽;

3、为了达到“酷炫智能”效果,在一加载和点击区县的时候,做了camera的动画(镜头移动、拉近),在这里就要在vue中引入tween.js了,tween做补间动画,还是很好用的;

4、地图边缘做了个流光效果,这个有很多厉害的博主介绍过,我是稍作了下修改;

5、每切换一个tab,隐藏/显示相应模型,所以把一组模型放到一组group里;

接下来我们可以带着上面几个点,看代码~!

项目使用vue的框架,我们先来看看项目目录、依赖都有哪些,其中引入elementUI就是为了用用里面的按钮,不用自己写了:

 (Menu.vue是测试了一个3D的菜单,跟此项目没有关联,可以先不用理会)

{
  "name": "default",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build"
  },
  "dependencies": {
    "@tweenjs/tween.js": "^18.6.4",
    "core-js": "^2.6.5",
    "element-ui": "^2.15.8",
    "three": "^0.140.2",
    "vue": "^2.6.10"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^3.8.0",
    "@vue/cli-service": "^3.8.0",
    "d3": "^7.4.4"
  }
}
 

tween这个包不好在vue里面直接用,所以提前去下载好,然后还要在main.js里面做声明

import Vue from 'vue'
import App from './App.vue'
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
// 补间动画
import tween from "./utils/tween";

Vue.use(ElementUI); 
Vue.use(tween);

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

接下来,我们看一下主要的代码Main.vue

<template>
  <div>
    <div id="container"></div>
    <div id="tooltip"></div>

    <el-button-group class="button-group">
      <el-button type="" icon="" @click="groupOneChange">首页总览</el-button>
      <el-button type="" icon="" @click="groupTwoChange">应急管理</el-button>
      <el-button type="" icon="" @click="groupThreeChange">能源管理</el-button>
      <el-button type="" icon="" @click="groupFourChange">环境监测</el-button>
      <!-- <el-button type="" icon="">综合能源监控中心</el-button> -->

    </el-button-group>
  </div>
</template>

其中:

container块是主要渲染3d画布的div;

tooltip是鼠标悬浮到区县时显示区县名称div;

button-group是左上部分做tab切换的按钮组(全篇引入了elementUI就在这用到了...)

 这是需要的组件,提前引入

  import * as THREE from "three";
  import * as d3 from 'd3';
  import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
  import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

下面是放在data里的属性,把摄像机、场景、控制器、城市上的数据、城市上的模型,都放在这先声明一下,因为牵扯到很多模型、摄像机、动画的逻辑变化,所以放到这就相当于全局变量,后续用的话都很方便。

    data() {
      return {
        camera: null,
        scene: null,
        renderer: null,
        labelRenderer: null,
        container: null,
        // mesh: null,
        controller: null,
        map: null,
        raycaster: null,
        mouse: null,
        tooltip: null,
        lastPick: null,
        mapEdgeLightObj: {
          mapEdgePoints: [],
          lightOpacityGeometry: null,  // 单独把geometry提出来,动画用

          // 边缘流光参数
          lightSpeed: 3,
          lightCurrentPos: 0,
          lightOpacitys: null,          
        },

        // 每个屏幕模型一组
        groupOne: new THREE.Group(),
        groupTwo: new THREE.Group(),
        groupThree: new THREE.Group(),
        groupFour: new THREE.Group(),


        // groupOne 统计信息
        cityWaveMeshArr: [],
        cityCylinderMeshArr: [],
        cityMarkerMeshArr: [],
        cityNumMeshArr: [],

        // groupTwo 告警信息
        alarmWaveMeshArr: [],
        alarmCylinderMeshArr: [],
        alarmNameMeshArr: [],

        // groupThree 能源
        energyWaveMeshArr: [],
        energyCylinderMeshArr: [],        
        energyNameMeshArr: [],
        
        // groupFour 环境
        monitorWaveMeshArr: [],
        monitorIconMeshArr: [],        
        monitorNameMeshArr: [],

        // 城市信息
        mapConfig: {
          deep: 0.2,
        },
        // 摄像机移动位置,初始:0, -5, 1
        cameraPosArr: [
          // {x: 0.0, y: -0.3, z: 1},
          // {x: 5.0, y: 5.0, z: 2},
          // {x: 3.0, y: 3.0, z: 2},
          // {x: 0, y: 5.0, z: 2},
          // {x: -2.0, y: 3.0, z: 1},
          {x: 0, y: -3.0, z: 3.8},
        ],

        // 数据 - 区县总数量
        dataTotal: [xxxxxx],
        dataAlarm: [xxxxxx],
        dataEnergy: [xxxxxx],
        dataMonitor: [xxxxxx],           
      };
    },

mounted函数不多说了,初始化什么的都放在这

    mounted() {
      this.init();
      this.animate();
      window.addEventListener('resize', this.onWindowSize)
    },

着重看一下methods里面的方法,首先是把three的几大基本元素初始化了

      //初始化
      init() {
        this.container = document.getElementById("container");
        this.setScene();
        this.setCamera();
        this.setRenderer();  // 创建渲染器对象
        this.setController();  // 创建控件对象
        this.addHelper();
        this.loadMapData();
        this.setEarth();
        this.setRaycaster();
        this.setLight();
      },

      setScene() {
        //  创建场景对象Scene
        this.scene = new THREE.Scene();
      },

      setCamera() {
        // 第二参数就是 长度和宽度比 默认采用浏览器  返回以像素为单位的窗口的内部宽度和高度
        this.camera = new THREE.PerspectiveCamera(
          75,
          window.innerWidth / window.innerHeight,
          0.1,
          500
        );

        this.camera.position.set(0, -5, 1);  // 0, -5, 1
        this.camera.lookAt(new THREE.Vector3(0, 0, 0));  // 0, 0, 0 this.scene.position
      },

      setRenderer() {
        this.renderer = new THREE.WebGLRenderer({ 
          antialias: true,
          // logarithmicDepthBuffer: true,  // 是否使用对数深度缓存
        });
        this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
        this.renderer.setPixelRatio(window.devicePixelRatio);
        // this.renderer.sortObjects = false;  // 是否需要对对象排序
        this.container.appendChild(this.renderer.domElement);


        this.labelRenderer = new CSS2DRenderer();
        this.labelRenderer.setSize(this.container.clientWidth, this.container.clientHeight);
        this.labelRenderer.domElement.style.position = 'absolute';
        this.labelRenderer.domElement.style.top = 0;
        this.container.appendChild(this.labelRenderer.domElement);
      },

      setController() {
        this.controller = new OrbitControls(this.camera, this.labelRenderer.domElement);
				this.controller.minDistance = 2;
				this.controller.maxDistance = 5.5  // 5.5

        // 阻尼(惯性)
        // this.controller.enableDamping = true;
				// this.controller.dampingFactor = 0.04;

        this.controller.minAzimuthAngle = -Math.PI / 4;
				this.controller.maxAzimuthAngle = Math.PI / 4;

        this.controller.minPolarAngle = 1;
				this.controller.maxPolarAngle = Math.PI - 0.1;

        // 修改相机的lookAt是不会影响THREE.OrbitControls的target的
        // this.controller.target = new THREE.Vector3(0, -5, 2); 
        
      },

      // 辅助线
      addHelper() {
        // let helper = new THREE.CameraHelper(this.camera);
        // this.scene.add(helper);

        //轴辅助 (每一个轴的长度)
        let axisHelper = new THREE.AxisHelper(150);  // 红线是X轴,绿线是Y轴,蓝线是Z轴
        // this.scene.add(axisHelper);

        let gridHelper = new THREE.GridHelper(100, 30, 0x2C2C2C, 0x888888);
        // this.scene.add(gridHelper);        
      },

      setLight() {
        const ambientLight = new THREE.AmbientLight(0x404040, 1.2);
        this.scene.add(ambientLight);
        // // 平行光
        const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
        this.scene.add(directionalLight); 

        // 聚光光源 - 照模型
        // const spotLight = new THREE.SpotLight(0xffffff, 0.9);
        // spotLight.position.set(1, -4, 4);
        // spotLight.castShadow = true;
        // this.scene.add(spotLight);
        // 聚光光源辅助线
        // const spotLightHelper = new THREE.SpotLightHelper(spotLight);
        // this.scene.add(spotLightHelper);

        // 点光源 - 照模型
        const test = new THREE.PointLight("#ffffff", 1.8, 20);
        test.position.set(1, -7, 7);
        this.scene.add(test);
        const testHelperMap = new THREE.PointLightHelper(test);
        this.scene.add(testHelperMap);

        // 点光源 - 蓝色照地球
        const pointLightMap = new THREE.PointLight("#4161ff", 1.4, 20);
        pointLightMap.position.set(0, 7, 3);
        this.scene.add(pointLightMap);
        const spotLightHelperMap = new THREE.PointLightHelper(pointLightMap);
        // this.scene.add(spotLightHelperMap);
      },

这里需要注意,renderer渲染器初始化的时候,除了正常的WebGLRenderer,别忘了CSS2DRenderer(为了在图上显示html的label),没用过这种的小伙伴,也可以先看一下官方的example:three.js examples

其他如果有不明白的,可以把three的官方文档看一下,在这就不过多说了

three.js docs

接下来就是根据地图的json,用d3的墨卡托投影来绘制地图模型了。在这里从static里,加载山东淄博市的json数据(这种json格式,不了解的可以查一下,对绘制地图也有帮助)

      // 加载地图数据
      loadMapData() {
        const loader = new THREE.FileLoader();
        loader.load("/static/map/json/zibo.json", data => {
          const jsondata = JSON.parse(data);
          this.addMapGeometry(jsondata);
        })
      },

      // 地图模型
      addMapGeometry(jsondata) {
        // 初始化一个地图对象
        this.map = new THREE.Object3D();
        // 墨卡托投影转换
        const projection = d3
          .geoMercator()
          .center([118.2, 36.7])  // 淄博市
          // .scale(2000) 
          .translate([0.2, 0.15]);  // 根据地球贴图做轻微调整

        jsondata.features.forEach((elem) => {
          // 定一个省份3D对象
          const province = new THREE.Object3D();
          // 每个的 坐标 数组
          const coordinates = elem.geometry.coordinates;
          // 循环坐标数组
          coordinates.forEach((multiPolygon) => {
            multiPolygon.forEach((polygon) => {
              const shape = new THREE.Shape();
              const lineMaterial = new THREE.LineBasicMaterial({
                color: '#ffffff',
                // linewidth: 1,
                // linecap: 'round', //ignored by WebGLRenderer
                // linejoin:  'round' //ignored by WebGLRenderer                
              });
              // const lineGeometry = new THREE.Geometry();
              // 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);
              //   lineGeometry.vertices.push(new THREE.Vector3(x, -y, 3));
              // }
              const lineGeometry = new THREE.BufferGeometry();
              const pointsArray = 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);
                pointsArray.push(new THREE.Vector3(x, -y, this.mapConfig.deep));

                // 做边缘流光效果,把所有点保存下来
                this.mapEdgeLightObj.mapEdgePoints.push([x, -y, this.mapConfig.deep]);
              }
              // console.log(pointsArray);
              lineGeometry.setFromPoints(pointsArray);
              
              const extrudeSettings = {
                depth: this.mapConfig.deep,
                bevelEnabled: false,  // 对挤出的形状应用是否斜角
              };

              const geometry = new THREE.ExtrudeGeometry(
                shape,
                extrudeSettings
              );
              const material = new THREE.MeshPhongMaterial({
                color: '#4161ff',
                transparent: true,
                opacity: 0.4,
                side: THREE.FrontSide,
                // depthTest: true,
              });
              const material1 = new THREE.MeshLambertMaterial({
                color: '#10004a',
                transparent: true,
                opacity: 0.7,
                side: THREE.FrontSide,
                // wireframe: true
              });
              const mesh = new THREE.Mesh(geometry, [material, material1]);
              const line = new THREE.Line(lineGeometry, lineMaterial);
              // 将省份的属性 加进来
              province.properties = elem.properties;

              // 将城市信息放到模型中,后续做动画用
              if (elem.properties.centroid) {
                  const [x, y] = projection(elem.properties.centroid)  // uv映射坐标
                  province.properties._centroid = [x, y]
              }

              // console.log(elem.properties);
              province.add(mesh);
              province.add(line);
            })
          })
          // province.scale.set(5, 5, 0);
          // province.position.set(0, 0, 0);
          // console.log(province);
          this.map.add(province);
        })
        this.setMapEdgeLight();
        this.setMapName();
        this.scene.add(this.map);

        // 获取数据后,加载模型
        this.getResponseData();

      },

这里需要注意几点:

1、d3.geoMercator().center([118.2, 36.7]) .translate([0.2, 0.15]),因为地球表面是一个plane模型,贴了一个真实的地图,所以有一些沟壑河流,要根据translate做轻微调整,使模型其更贴合。

2、lineGeometry.vertices在高版本的three库中已弃用,改用BufferGeometry了

3、在循环所有地图边界点的时候,保存到了mapEdgePoints中,后续做地图边缘流光效果的时候用的上

4、整体思路就是,把地图先绘制成一个平面,然后通过ExtrudeGeometry模型拉一个深度,这个地图再贴到地球表面这个plane模型上,就ok了

市区地图的模型有了,接下来我们看下,如何在边界加一圈流光效果

      // 地图边缘流光效果
      setMapEdgeLight() {
        // console.log(this.mapEdgeLightObj.mapEdgePoints);
        let positions = new Float32Array(this.mapEdgeLightObj.mapEdgePoints.flat(1));  // 数组深度遍历扁平化
        // console.log(positions);
        this.mapEdgeLightObj.lightOpacityGeometry = new THREE.BufferGeometry();
        // 设置顶点
        this.mapEdgeLightObj.lightOpacityGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
        // 设置 粒子透明度为 0
        this.mapEdgeLightObj.lightOpacitys = new Float32Array(positions.length).map(() => 0);
        this.mapEdgeLightObj.lightOpacityGeometry.setAttribute("aOpacity", new THREE.BufferAttribute(this.mapEdgeLightObj.lightOpacitys, 1));

        // 顶点着色器
        const vertexShader = `
          attribute float aOpacity;
          uniform float uSize;
          varying float vOpacity;

          void main(){
              gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);
              gl_PointSize = uSize;

              vOpacity=aOpacity;
          }
          `
        // 片段着色器
        const fragmentShader = `
          varying float vOpacity;
          uniform vec3 uColor;

          float invert(float n){
              return 1.-n;
          }

          void main(){
            if(vOpacity <=0.2){
                discard;
            }
            vec2 uv=vec2(gl_PointCoord.x,invert(gl_PointCoord.y));
            vec2 cUv=2.*uv-1.;
            vec4 color=vec4(1./length(cUv));
            color*=vOpacity;
            color.rgb*=uColor;
            gl_FragColor=color;
          }
          `
        
        const material = new THREE.ShaderMaterial({
          vertexShader: vertexShader,
          fragmentShader: fragmentShader,
          transparent: true, // 设置透明
          // blending: THREE.AdditiveBlending,
          uniforms: {
            uSize: {
              value: 5.0
            },
            uColor: {
              value: new THREE.Color("#ffffff")  // 光点颜色 fffb85
            }
          }
        })
        // material.blending = THREE.AdditiveBlending;
        const opacityPointsMesh = new THREE.Points(this.mapEdgeLightObj.lightOpacityGeometry, material);
        this.scene.add(opacityPointsMesh);

      },
      // 动画 - 城市边缘流光
      animationCityEdgeLight() {
        if(this.mapEdgeLightObj.lightOpacitys && this.mapEdgeLightObj.mapEdgePoints) {
          if (this.mapEdgeLightObj.lightCurrentPos > this.mapEdgeLightObj.mapEdgePoints.length) {
            this.mapEdgeLightObj.lightCurrentPos = 0;
          }

          this.mapEdgeLightObj.lightCurrentPos += this.mapEdgeLightObj.lightSpeed;
          for (let i = 0; i < this.mapEdgeLightObj.lightSpeed; i++) {
            this.mapEdgeLightObj.lightOpacitys[(this.mapEdgeLightObj.lightCurrentPos - i) % this.mapEdgeLightObj.mapEdgePoints.length] = 0;
          }

          for (let i = 0; i < 100; i++) {
            this.mapEdgeLightObj.lightOpacitys[(this.mapEdgeLightObj.lightCurrentPos + i) % this.mapEdgeLightObj.mapEdgePoints.length] = i / 50 > 2 ? 2 : i / 50;
          }

          if (this.mapEdgeLightObj.lightOpacityGeometry) {
            this.mapEdgeLightObj.lightOpacityGeometry.attributes.aOpacity.needsUpdate = true;
          }
        }
      },

这里的整体思路是,之前已经把边界的点保存下来了,点一个接一个的亮,就形成了好看的流光效果。

animationCityEdgeLight方法是在animate中的,每一帧画面如何动的,可以先理解一下,后期我们一起讲。

 接下来我们看下地表的模型和贴图

      // 地球贴图纹理
      setEarth() {
        const geometry = new THREE.PlaneGeometry(14.0, 14.0);
        const texture = new THREE.TextureLoader().load('/static/map/texture/earth.jpg');
        const bumpTexture = new THREE.TextureLoader().load('/static/map/texture/earth.jpg');
        // texture.wrapS = THREE.RepeatWrapping;  // 质地.包裹
        // texture.wrapT = THREE.RepeatWrapping;
        
        const material = new THREE.MeshPhongMaterial({
          map: texture,  // 贴图
          bumpMap: bumpTexture,
          bumpScale: 0.05,
          // specularMap: texture,
          // specular: 0xffffff,
          // shininess: 1,
          // color: "#000000", 
          side: THREE.FrontSide} 
        );
        const earthPlane = new THREE.Mesh(geometry, material);
        this.scene.add(earthPlane);        
      },

这里用了bumpTexture纹理,让地表有那么一点点沟壑,这个可以调整一下自己感受一下

 地图区县的label

      // 地图label
      setMapName(){
        this.map.children.forEach((elem, index) => {
          // 找到中心点
          const y = -elem.properties._centroid[1]
          const x = elem.properties._centroid[0]
          // 转化为二维坐标
          const vector = new THREE.Vector3(x, y, this.mapConfig.deep + 0.01)

          // 添加城市名称
          this.setCityName(vector, elem.properties.name);
        })
      },
      // 城市 - 名称显示
      setCityName(vector, name) {
        let spritey = this.makeTextSprite(
          name, 
          {
            fontface: "微软雅黑",
            fontsize: 28,  // 定100调整位置,下面通过scale缩放
            fontColor: {r: 255, g: 255, b: 255, a: 1.0}, 
            borderColor: {r: 94, g: 94, b: 94, a: 0.0}, 
            backgroundColor: {r: 255, g: 255, b: 0, a: 0.0},
            borderThickness: 2,
            round: 6
          }
        );
        // 轻微偏移,错开光柱
        spritey.position.set(vector.x + 0.06, vector.y + 0.0, 0.22);  // num + 0.3
        this.scene.add(spritey);     
      },

      // 城市 - 名称显示 - 小精灵mesh
      makeTextSprite(message, parameters) {
        if (parameters === undefined) parameters = {};
        
        let fontface = parameters["fontface"];
        let fontsize = parameters["fontsize"];
        let fontColor = parameters["fontColor"];
        let borderThickness = parameters["borderThickness"];
        let borderColor = parameters["borderColor"];
        let backgroundColor = parameters["backgroundColor"];

        // var spriteAlignment = THREE.SpriteAlignment.topLeft;
          
        let canvas = document.createElement('canvas');
        let context = canvas.getContext('2d');
        context.font = "Bold " + fontsize + "px " + fontface;
          
        // get size data (height depends only on font size)
        let metrics = context.measureText(message);
        let textWidth = metrics.width;
        
        // background color
        context.fillStyle   = "rgba(" + backgroundColor.r + "," + backgroundColor.g + "," + backgroundColor.b + "," + backgroundColor.a + ")";
        // border color
        context.strokeStyle = "rgba(" + borderColor.r + "," + borderColor.g + "," + borderColor.b + "," + borderColor.a + ")";

        context.lineWidth = borderThickness;
        const painting = {
          width: textWidth * 1.4 + borderThickness * 2,
          height: fontsize * 1.4 + borderThickness * 2,
          round: parameters["round"]
        };
        // 1.4 is extra height factor for text below baseline: g,j,p,q.
        // context.fillRect(0, 0, painting.width, painting.height)
        this.roundRect(
          context, 
          borderThickness / 2, 
          borderThickness / 2, 
          painting.width, 
          painting.height, 
          painting.round
        );
        
        // text color
        context.fillStyle = "rgba(" + fontColor.r + "," + fontColor.g + "," + fontColor.b + "," + fontColor.a + ")";
        context.textAlign = "center";
        context.textBaseline = "middle";
        
        context.fillText(message, painting.width / 2, painting.height / 2);
        
        // canvas contents will be used for a texture
        let texture = new THREE.Texture(canvas) 
        texture.needsUpdate = true;
        let spriteMaterial = new THREE.SpriteMaterial({ 
          map: texture, 
          useScreenCoordinates: false,
          depthTest: false,  // 解决精灵谍影问题
          // blending: THREE.AdditiveBlending,
          // transparent: true,
          // alignment: spriteAlignment
        });
        let sprite = new THREE.Sprite(spriteMaterial);
        sprite.scale.set(1, 1 / 2, 1);
        return sprite;	
      },
      // 城市 - 名称显示 - 样式
      roundRect(ctx, x, y, w, h, r) {
        ctx.beginPath();
        ctx.moveTo(x+r, y);
        ctx.lineTo(x+w-r, y);
        ctx.quadraticCurveTo(x+w, y, x+w, y+r);
        ctx.lineTo(x+w, y+h-r);
        ctx.quadraticCurveTo(x+w, y+h, x+w-r, y+h);
        ctx.lineTo(x+r, y+h);
        ctx.quadraticCurveTo(x, y+h, x, y+h-r);
        ctx.lineTo(x, y+r);
        ctx.quadraticCurveTo(x, y, x+r, y);
        ctx.closePath();
        ctx.fill();
        ctx.stroke();   
      },

这里没什么,因为要让label每次都要冲着camera,就是用到了小精灵模型,然后手动canvas画了下,不过感觉展示效果不好,但是也算个画canvas的知识点了

下面介绍一下,获取区县中心点这个方法,后续会用到很多次,各种模型的展示基本都要基于这个定位。

      // 地区中心点 - 获取向量
      mapElem2Centroid(elem) {
        // 找到中心点
        const y = -elem.properties._centroid[1];
        const x = elem.properties._centroid[0];
        // 转化为二维坐标
        const vector = new THREE.Vector3(x, y, this.mapConfig.deep + 0.01);
        return vector;
      },

 接下来我们看一下如何往地图上,添加数据上的模型,这里要提前讲一下,后台获取的数据我们是不确定的,地图就这么大,不可能根据数值无限放大、缩小模型,那样效果很不好,所以,在一开始我们就要把数据做【归一化】处理,顾名思义,就是把数据都放到0-1之间,再根据这个比例来定模型多大

      // 数据归一化,映射到0-1区间 - 获取最大值
      getMaxV(distributionInfo) {
        let max = 0;
        for (let item of distributionInfo) {
          if (max < item.total) max = item.total;
        }
        return max;
      },
      // 数据归一化,映射到0-1区间 - 获取最小值
      getMinV(distributionInfo) {
        let min = 1000000;
        for (let item of distributionInfo) {
          if (min > item.total) min = item.total;
        }
        return min;
      },
      // 数据归一化,映射到0-1区间
      normalization(data, min, max) {
        let normalizationRatio = (data - min) / (max - min)
        return normalizationRatio
      },

      // GroupOne 添加模型
      addCityModel() {
        // 数据归一化
        const min = this.getMinV(this.dataTotal);
        const max = this.getMaxV(this.dataTotal);
        // 添加模型
        this.map.children.forEach((elem, index) => {
          // console.log(elem);
          // 满足数据条件 dataTotal
          if(this.dataTotal) {
            const vector = this.mapElem2Centroid(elem);
            this.dataTotal.forEach(d => {
              // 数据归一化,映射到0-1区间
              let num = this.normalization(d.total, min, max);

              // 判断区县
              if(d.name === elem.properties.name) {
                // 添加城市光波
                this.setCityWave(vector);

                // 添加城市标记
                this.setCityMarker(vector);
                            
                // 添加城市光柱
                this.setCityCylinder(vector, num);

                // 添加城市数据
                this.setCityNum(vector, num, d);
              }
            })
            this.scene.add(this.groupOne);
          }
        })
      },

这里我们展示第一个tab的城市模型(其它tab的同理),这个tab里,用addCityModel这个方法里,循环把各种模型添加进去;

这个包含几种模型:城市光波(从城市中央扩散)、标记(自转)、光柱、数据,具体对照可以看一下下图,一目了然

wave
marker

 接下来,我们看下每类模型是怎么创建的

      // 城市 - 光柱
      setCityCylinder(vector, num) {
        const height = num;
        const geometry = new THREE.CylinderGeometry(0.08, 0.08, height, 20);
        
        // 顶点着色器
        const vertexShader = `
          uniform vec3 viewVector;
          varying float intensity;
          void main() {
              gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
              vec3 actual_normal = vec3(modelMatrix * vec4(normal, 0.0));
              intensity = pow(dot(normalize(viewVector), actual_normal), 3.0);
          }
          `
        // 片段着色器
        const fragmentShader = `
          varying float intensity;
          void main() {
            vec3 glow = vec3(246, 239, 0) * 3.0;
            gl_FragColor = vec4(glow, 1);
          }
          `
        
        let material = new THREE.MeshPhongMaterial({  // ShaderMaterial
            // uniforms: {
            //     viewVector: this.camera.position
            // },
            // vertexShader: vertexShader,
            // fragmentShader: fragmentShader,
            color: "#ede619",
            side: THREE.FrontSide,
            blending: THREE.AdditiveBlending,
            transparent: true,
            // depthTest: false,
            precision: "mediump",
            // depthFunc: THREE.LessEqualDepth,
            opacity: 0.9,
        });

        const cylinder = new THREE.Mesh(geometry, material);
        cylinder.position.set(vector.x, vector.y, vector.z + height / 2);
        cylinder.rotateX(Math.PI / 2);
        cylinder.scale.set(1, 1, 1);
        // cylinder.position.z -= height / 2;
        // cylinder.translateY(-height);
        cylinder._height = height;

        // 法向量计算位置
        // let coordVec3 = vector.normalize();
        // // mesh默认在XOY平面上,法线方向沿着z轴new THREE.Vector3(0, 0, 1)
        // let meshNormal = new THREE.Vector3(0, 0, 0);
        // // 四元数属性,角度旋转,quaternion表示mesh的角度状态,setFromUnitVectors();计算两个向量之间构成的四元数值
        // cylinder.quaternion.setFromUnitVectors(meshNormal, coordVec3);
        this.cityCylinderMeshArr.push(cylinder);
        this.groupOne.add(cylinder);
        // this.scene.add(cylinder);
      },

      // 城市 - 光波
      setCityWave(vector) {
        const cityGeometry = new THREE.PlaneBufferGeometry(1, 1); //默认在XOY平面上
        const textureLoader = new THREE.TextureLoader(); // TextureLoader创建一个纹理加载器对象
        const texture = textureLoader.load('/static/map/texture/wave.png');

        // 如果不同mesh材质的透明度、颜色等属性同一时刻不同,材质不能共享
        const cityWaveMaterial = new THREE.MeshBasicMaterial({
          color: "#ede619",  // 0x22ffcc
          map: texture,
          transparent: true, //使用背景透明的png贴图,注意开启透明计算
          opacity: 1.0,
          side: THREE.FrontSide, //双面可见
          depthWrite: false, //禁止写入深度缓冲区数据
          blending: THREE.AdditiveBlending,
        });

        let cityWaveMesh = new THREE.Mesh(cityGeometry, cityWaveMaterial);
        cityWaveMesh.position.set(vector.x, vector.y, vector.z);
        cityWaveMesh.size = 0; 
        // cityWaveMesh.scale.set(0.1, 0.1, 0.1);  // 设置mesh大小

        // 法向量计算位置
        // let coordVec3 = vector.normalize();
        // // mesh默认在XOY平面上,法线方向沿着z轴new THREE.Vector3(0, 0, 1)
        // let meshNormal = new THREE.Vector3(0, 0, 0);
        // // 四元数属性,角度旋转,quaternion表示mesh的角度状态,setFromUnitVectors();计算两个向量之间构成的四元数值
        // cityWaveMesh.quaternion.setFromUnitVectors(meshNormal, coordVec3);
        this.cityWaveMeshArr.push(cityWaveMesh);
        this.groupOne.add(cityWaveMesh);
        // 添加到场景中
        // this.scene.add(cityWaveMesh);
      },

      // 城市 - 标记
      setCityMarker(vector) {
        const cityGeometry = new THREE.PlaneBufferGeometry(0.3, 0.3); //默认在XOY平面上
        const textureLoader = new THREE.TextureLoader(); // TextureLoader创建一个纹理加载器对象
        const texture = textureLoader.load('/static/map/texture/marker.png');

        // 如果不同mesh材质的透明度、颜色等属性同一时刻不同,材质不能共享
        const cityMaterial = new THREE.MeshBasicMaterial({
          color: "#ffe000",  // 0x22ffcc
          map: texture,
          transparent: true, //使用背景透明的png贴图,注意开启透明计算
          opacity: 1.0,
          side: THREE.FrontSide, //双面可见
          depthWrite: false, //禁止写入深度缓冲区数据
          blending: THREE.AdditiveBlending,
        });
        cityMaterial.blending = THREE.CustomBlending;
        cityMaterial.blendSrc = THREE.SrcAlphaFactor;
        cityMaterial.blendDst = THREE.DstAlphaFactor;
        cityMaterial.blendEquation = THREE.AddEquation;

        let cityMarkerMesh = new THREE.Mesh(cityGeometry, cityMaterial);
        cityMarkerMesh.position.set(vector.x, vector.y, vector.z);
        cityMarkerMesh.size = 0; 
        // cityWaveMesh.scale.set(0.1, 0.1, 0.1);  // 设置mesh大小

        this.cityMarkerMeshArr.push(cityMarkerMesh);
        this.groupOne.add(cityMarkerMesh);
        // 添加到场景中
        // this.scene.add(cityMarkerMesh);  
      },

      // 城市 - 数据显示
      setCityNum(vector, num, data) {
        // CSS2DRenderer生成的标签直接就是挂在真实的DOM上,并非是Vue的虚拟DOM上
				const div = document.createElement('div');
				div.className = 'city-num-label';
				div.textContent = data.total;

				const contentDiv = document.createElement('div');
				contentDiv.className = 'city-num-label-content';
				contentDiv.innerHTML = 
          '本区县共有窑炉企业 ' + data.total + ' 个。<br/>' +
          '介绍:' + data.brief
        ;
        div.appendChild(contentDiv);

				const label = new CSS2DObject(div);
				label.position.set(vector.x, vector.y, num + 0.5);
        label.visible = true;
        this.cityNumMeshArr.push(label);
        this.groupOne.add(label);
        // this.scene.add(spritey);

      },

我们来讲解一下每种模型的创建思路:

1、光柱:就是圆柱体,然后附上效果,需要注意的是,圆柱体的高度怎么计算呢?记得我们刚才用的归一函数吗,就是在这里计算高度的。

2、光波:一个透明png,贴到一个plane模型上,然后把融合模式改一下blending: THREE.AdditiveBlending。更多融合的效果,可以见官方例子 three.js examples

3、标记:比较像光波,也是贴图到plane上。

4、数据:这里用到我们之前讲的CSS2DRenderer,注意CSS2DRenderer生成的标签直接就是挂在真实的DOM上,并非是Vue的虚拟DOM上。然后直接把样式写到css里,鼠标悬浮显示,就用一个:hover,非常好用。

这里还需要注意,因为这些模型都是tab 1里的,所以都放到groupOne这个变量里,后续做切换好用(替他tab里的模型同理)

我们鼠标悬浮到地图上,可以识别,可以显示label,这得益于three的raycaster,简单看一下代码,很多博主已经讲过了,这里就不过多赘述了。

      // 射线
      setRaycaster() {
        this.raycaster = new THREE.Raycaster();
        this.mouse = new THREE.Vector2();
        this.tooltip = document.getElementById('tooltip');
        const onMouseMove = (event) => {
          this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
          this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
          this.tooltip.style.left = event.clientX + 2 + 'px';
          this.tooltip.style.top = event.clientY + 2 + 'px';
        }

        // 点击地图事件
        const onClick = (event) => {
          // console.log(this.lastPick);
          if(this.lastPick && "point" in this.lastPick) this.mapClickTween(this.lastPick.point);
          else this.resetCameraTween();
        }

        window.addEventListener('mousemove', onMouseMove, false);
        window.addEventListener('click', onClick, false);

      },

      // 鼠标悬浮显示
      showTip() {
        // 显示省份的信息
        if (this.lastPick) {
          const properties = this.lastPick.object.parent.properties;

          this.tooltip.textContent = properties.name;

          this.tooltip.style.visibility = 'visible';
        } else {
          this.tooltip.style.visibility = 'hidden';
        }
      },

      // 窗口变化
      onWindowSize() {
        // let container = document.getElementById("container");
        this.camera.aspect = this.container.clientWidth / this.container.clientHeight;
        this.camera.updateProjectionMatrix();
        this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
        this.labelRenderer.setSize(this.container.clientWidth, this.container.clientHeight);
      },

地图点击有一些事件的触发,这就避免不了需要移动摄像机。

比如:点击区县,摄像机拉进;点击空白,摄像机归位。页面加载完成时,摄像机从地表移动到现在的位置(增加酷炫性,领导喜欢0.0)

      // Tween - 加载时相机移动动画
      cameraTween(i) {
        // console.log("cameraTween");

        !i ? i = 0 : i = i;
        if(i > this.cameraPosArr.length - 1) {
          // this.cityCylinderTween();
          return false;
        }

        //关闭控制器
			  this.controller.enabled = false;

        const begin = {
          x: this.camera.position.x,
          y: this.camera.position.y,
          z: this.camera.position.z,
        };
        const end = {
          x: this.cameraPosArr[i].x,
          y: this.cameraPosArr[i].y,
          z: this.cameraPosArr[i].z,          
          // x: 0,
          // y: -3.0,
          // z: 3.8,
        };
        const self = this;
        this.$tween.use({
            begin,
            end,
            time: 1500,
            onUpdate(obj) {
              self.camera.position.x = obj.x;
              self.camera.position.y = obj.y;
              self.camera.position.z = obj.z;
              
              // self.controller.target.x = obj.x;
              // self.controller.target.y = obj.y;
              // self.controller.target.z = obj.z;

              // 控制器更新
              self.controller.update();
            },
            onComplete() {
              self.controller.enabled = true;
              self.cameraTween(i+1);
            }
        });
      },
      
      // Tween - 点击省份动画
      mapClickTween(pos) {
        //关闭控制器
			  this.controller.enabled = false;

        const begin = {
          x: this.camera.position.x,
          y: this.camera.position.y,
          z: this.camera.position.z,
        };
        const end = {
          x: pos.x,
          y: pos.y,
          z: pos.z + 2.5,          
        };
        const self = this;
        this.$tween.use({
            begin,
            end,
            time: 500,
            onUpdate(obj) {
              self.camera.position.x = obj.x;
              self.camera.position.y = obj.y;
              self.camera.position.z = obj.z;

              self.camera.lookAt(obj.x, obj.y, obj.z);

              // 控制器更新
              self.controller.update();
            },
            onComplete() {
              self.controller.enabled = true;
            }
        });
      },

      // Tween - 重置相机
      resetCameraTween() {
        //关闭控制器
			  this.controller.enabled = false;

        const begin = {
          x: this.camera.position.x,
          y: this.camera.position.y,
          z: this.camera.position.z,
        };
        const end = {
          x: this.cameraPosArr[this.cameraPosArr.length - 1].x,
          y: this.cameraPosArr[this.cameraPosArr.length - 1].y,
          z: this.cameraPosArr[this.cameraPosArr.length - 1].z,          
        };
        const self = this;
        this.$tween.use({
            begin,
            end,
            time: 500,
            onUpdate(obj) {
              self.camera.position.x = obj.x;
              self.camera.position.y = obj.y;
              self.camera.position.z = obj.z;

              self.camera.lookAt(0, 0, 0);

              // 控制器更新
              self.controller.update();
            },
            onComplete() {
              self.controller.enabled = true;
            }
        });
      },

动画,就会用到神库Tween了,之前我们也引入了。

需要着重注意的一点,在camera运动的时候,一定把控制器给关了,要不会...

this.controller.enabled = false;

然后别的也没什么了,一个begin、一个end,动就完事了

最后我们看一下animation的方法,我们的光波、城市标记怎么动,都在这里了

      // 动画
      animate() {
        requestAnimationFrame(this.animate);

        this.showTip();
        this.animationMouseover();
        
        // city
        this.animationCityWave();
        this.animationCityMarker();
        this.animationCityCylinder();
        this.animationCityEdgeLight();
        
        
        this.controller.update();
        this.renderer.render(this.scene, this.camera);
        this.labelRenderer.render(this.scene, this.camera);
      },
      // 动画 - 鼠标悬浮动作
      animationMouseover() {
        // 通过摄像机和鼠标位置更新射线
        this.raycaster.setFromCamera(this.mouse, this.camera)
        // 计算物体和射线的焦点,与当场景相交的对象有那些
        const intersects = this.raycaster.intersectObjects(
          this.scene.children,
          true  // true,则同时也会检测所有物体的后代
        )
        // 恢复上一次清空的
        if (this.lastPick) {
          this.lastPick.object.material[0].color.set('#4161ff');
          // this.lastPick.object.material[1].color.set('#00035d');
        }
        this.lastPick = null;
        this.lastPick = intersects.find(
          (item) => item.object.material && item.object.material.length === 2  // 选择map object
        )
        if (this.lastPick) {
          this.lastPick.object.material[0].color.set('#00035d');
          // this.lastPick.object.material[1].color.set('#00035d');
        }
      },

      // 动画 - 城市光柱
      animationCityCylinder() {

        this.cityCylinderMeshArr.forEach(mesh => {
          // console.log(mesh);
          
          // 着色器动作
          // let viewVector = new THREE.Vector3().subVectors(this.camera.position, mesh.getWorldPosition());
          // mesh.material.uniforms.viewVector.value = this.camera.position;

          // mesh.translateY(0.05);
          // mesh.position.z <= mesh._height * 2 ? mesh.position.z += 0.05 : "";

          // mesh.scale.z <= 1 ? mesh.scale.z += 0.05 : "";

        })          
      },

      // 动画 - 城市光波
      animationCityWave() {
        // console.log(this.cityWaveMesh);
        this.cityWaveMeshArr.forEach(mesh => {
          // console.log(mesh);
          mesh.size += 0.005;  // Math.random() / 100 / 2
          let scale = mesh.size / 1;
          mesh.scale.set(scale, scale, scale);
          if(mesh.size <= 0.5) {
            mesh.material.opacity = 1;
          } else if (mesh.size > 0.5 && mesh.size <= 1) {
            mesh.material.opacity = 1.0 - (mesh.size - 0.5) * 2;  // 0.5以后开始加透明度直到0
          } else if (mesh.size > 1 && mesh.size < 2) {
            mesh.size = 0;
          }
        })
      },
      // 动画 - 城市标记
      animationCityMarker() {
        this.cityMarkerMeshArr.forEach(mesh => {
          // console.log(mesh);
          mesh.rotation.z += 0.05;
        })        
      },

本来光柱做的是从地上慢慢上升的,后来为了做其他逻辑屏蔽了,直接就立在那了...

这里着重看一下城市光波:它是从中心开始慢慢扩大,到一定条件是慢慢透明度变为0。

最后,看一下tab点击有什么逻辑吧

      // 切换Group形态
      groupOneChange() {
        console.log("groupOneChange");
        // CSS2DObject数据单独做处理
        this.cityNumMeshArr.forEach(e => {e.visible = true});
        this.alarmNameMeshArr.forEach(e => {e.visible = false});
        this.energyNameMeshArr.forEach(e => {e.visible = false});
        this.monitorNameMeshArr.forEach(e => {e.visible = false});

        this.groupOne.visible = true;
        this.groupTwo.visible = false;
        this.groupThree.visible = false;
        this.groupFour.visible = false;

      },

 到这里,就知道为什么要提前把tab的模型进行分组放了

 好啦,到这里就介绍完了,

如果有问题!

如果你也喜欢前端!

如果你也喜欢可视化!

如果你也喜欢3D世界!

欢迎评论区和私信交流~

最后附上代码,有需要的小伙伴可以一键run起来哦(觉得有用就star一下哦~)

GitHub - puyeyu/ThreeJs-Earth

本文章已经生成可运行项目
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ethanpu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值