[轉]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一下哦~)

GitHub - puyeyu/ThreeJs-Earth

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章