[转]Three.js做一个酷炫的城市展示可视化大屏
【保姆进阶级】Three.js做一个酷炫的城市展示可视化大屏
ethanpu 原文链接:https://blog.csdn.net/ethanpu/article/details/125691957
hi,大家好,我是ethan。
想记录博客很久了,一直懒得开个头,以前写过全栈、java、写过python、写过前端,写过安全、写过互联网,但是我还是更喜欢前端可视化,平时也喜欢研究一下可视化的技术,也是从d3、gis、threejs、echarts、hicharts、cesium一步步淌过来的,可视化方向的路还有很长,我觉得一些shader实在是好难....
web3.0盛行,元宇宙也是跟前端密切相关的,也想学习一下unity、three.ar.js之类的,有想法的小伙伴可以一起沟通一下~
言归正传,最近呢在做一个可视化大屏,当然要炫,毕竟领导喜欢,废话不多说,先上预览:
bb185a2e-b902-48eb-91a6-5ea79eaf53c9
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一下哦~)