Three.js 实战【4】—— 3D地图渲染
初始化场景&准备工作
在vue3+threejs当中,初始化场景的代码基本上是一样的,可以参考前面几篇文章的初始化场景代码。在这里进行渲染3D地图还需要用到d3这个库,所以需要安装一下d3,直接npm i即可。
再从阿里云这里提供的全国各个省市的地图json数据下载一份自己需要展示的json数据。初始化的区别在于需要创建一个渲染器
- CSS3DRenderer用于通过CSS3的transform属性, 将层级的3D变换应用到DOM元素上。 如果你希望不借助基于canvas的渲染来在你的网站上应用3D变换,那么这一渲染器十分有趣。 同时,它也可以将DOM元素与WebGL的内容相结合
- CSS2DRenderer是CSS3DRenderer的简化版本,唯一支持的变换是位移
// 创建渲染器
const labelRenderer = new CSS2DRenderer();
const addRenderer = () => {
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.pointerEvents = 'none';
labelRenderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('map').appendChild(labelRenderer.domElement);
};
创建材质
这里封装一个方法,用来处理不同块的数据。这是在json文件当中他会按照不同的省市进行区分不同的块。
Shape:使用路径以及可选的孔洞来定义一个二维形状平面
- 首先创建了一个多边形对象,之后循环data数组(这个data数组就对应着json里面的coordinates数组的下一层数组,这个等下调用这个方法的时候组装进来)
- 然后循环创建点。offsetXY是d3当中的一个方法,他的作用是将经纬度给转换成xy坐标,因为json和three里面的坐标系不是一样的所以需要转换一层
- 再进行判断,如果是第一个点就是起点,那么就用moveTo方法移动到这个点开始绘制,如果不是第一个点就用lineTo绘制一条线过去。这里使用了负的y值,因为Three.js的坐标系与地理坐标系的y轴方向相反
ExtrudeGeometry:挤压缓冲几何体(从一个形状路径中,挤压出一个BufferGeometry)他也是BufferGeometry的一个子类
- 上一步已经创建好了一个Shape形状了之后,需要再进一步创建一个几何体,使用到是这个ExtrudeGeometry(看名字我们也知道是要做什么了)
- 直接构造
- depth:float类型,挤出的形状的深度,默认值为1
- bevelEnabled:boolean类型,对挤出的形状应用是否斜角,默认值为true
MeshStandardMaterial:标准网格材质
- color:材质颜色
- emissive:材质的放射(光)颜色,基本上是不受其他光照影响的固有颜色。默认为黑色
- roughness:材质的粗糙程度。0.0表示平滑的镜面反射,1.0表示完全漫反射
- metalness:材质与金属的相似度。非金属材质,如木材或石材,使用0.0,金属使用1.0
- transparent:定义此材质是否透明。这对渲染有影响,因为透明对象需要特殊处理,并在非透明对象之后渲染。设置为true时,通过设置材质的opacity属性来控制材质透明的程度
- side:定义将要渲染哪一面 。正面,背面或两者。 默认为THREE.FrontSide。其他选项有THREE.BackSide 和 THREE.DoubleSide
最后通过Mesh将几何体和材质添加到一起进行返回
const offsetXY = d3.geoMercator();
/**
* 绘制每个市的区域
* @param data 坐标数据
* @param color 颜色
* @param depth 深度
* */
const createMesh = (data, color, depth) => {
const shape = new THREE.Shape();
data.forEach((item, idx) => {
const [x, y] = offsetXY(item);
if (idx === 0) shape.moveTo(x, -y);
else shape.lineTo(x, -y);
});
const extrudeSettings = {
depth,
bevelEnabled: false
};
const materialSettings = {
color: color,
emissive: 0x000000,
roughness: 0.45,
metalness: 0.8,
transparent: true,
side: THREE.DoubleSide
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshStandardMaterial(materialSettings);
return new THREE.Mesh(geometry, material);
};
渲染地图
在上一步创建材质的时候就把所需要渲染的材质都给创建好了,这一步只需要导入json,然后把json对应的data经纬度数据传给createMesh方法就大功告成了。接下来我们看一下渲染地图的方法
- 先取json文件里面第一个子对象的经纬度出来作为默认的中心点
- Object3D这是Three.js中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵,后续所有创建的mesh等等都加在这个里面
- 然后遍历json内容去找需要的data数据,这里需要通过MultiPolygon多重多边形 和 Polygon多边形,两者的区别在于找到对应的数据层级是不一样的,相差一层,可以对比一个内蒙古和其他省的数据。
- 最后找到那一层数据就调用createMesh创建材质的方法,得到材质之后加到Object3D对象里面,最后加到场景里面就完成了
/**
* @param data 完整的json数据
*/
const createMap = (data) => {
const map = new THREE.Object3D();
const center = data.features[0].properties.centroid;
// d3的方法表示将center作为xy坐标系的0,0点
offsetXY.center(center).translate([0, 0]);
data.features.forEach((feature) => {
const unit = new THREE.Object3D();
const {name, adcode} = feature.properties;
const {coordinates, type} = feature.geometry;
const depth = 1;
coordinates.forEach((coordinate) => {
if (type === 'MultiPolygon') coordinate.forEach((item) => fn(item));
if (type === 'Polygon') fn(coordinate);
function fn(coordinate) {
unit.name = name;
unit.adcode = adcode;
const mesh = createMesh(coordinate, '#63bbd0', depth);
unit.add(mesh);
}
});
map.add(unit);
});
scene.add(map);
};
结果这一步我们就可以直接查看效果了。但是由于最开始指定的是第一个数据的点为中心点也就是北京,渲染出来的并没有居中,并且最开始给设置的相机位置是(0,64,64)这样看起来也不对劲。
居中处理
在上一步scene.add(map)之前调用该方法。
- roration 先给map对象旋转-90度,让整个地图正面朝上
- 创建Box3对象,用setFromObject方法根据地图对象计算出其包围盒,再通过getCenter拿到中心点
- 因为最开始我们设置的中心点是(0,0,0)也就是重新设置中心点直接等于负center即可,后续如果设置的中心点不是(0,0,0)就这样计算一遍,这样整个地图也就居中展示了
const setCenter = (map) => {
map.rotation.x = -Math.PI / 2;
const box = new THREE.Box3().setFromObject(map);
const center = box.getCenter(new THREE.Vector3());
map.position.x = map.position.x - center.x;
map.position.z = map.position.z - center.z;
};
添加行政区边界线
在创建各个块元素材质的时候就已经可以拿到边界线的数据了,这里封装一个方法。
- 先把经纬度转成xy坐标然后存在point里面
- 创建BufferGeometry,可以直接通过setFromPoints将所有的点数据给塞进去,这样就创建了一个Geometry
- 再创建两个材质只要指定一下线的颜色,然后调整一下他们的z坐标,返回出去即可
- 最后在初始化地图的地方一起调用即可
const createLine = (data, depth) => {
const points = [];
data.forEach((item) => {
const [x, y] = offsetXY(item);
points.push(new THREE.Vector3(x, -y, 0));
});
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
// 地图上面的线
const upLineMaterial = new THREE.LineBasicMaterial({color: '#000000'});
// 地图下面的线
const downLineMaterial = new THREE.LineBasicMaterial({color: '#000000'});
const upLine = new THREE.Line(lineGeometry, upLineMaterial);
const downLine = new THREE.Line(lineGeometry, downLineMaterial);
downLine.position.z = -0.1;
upLine.position.z = depth + 0.1;
return [upLine, downLine];
};
// 调用这个:创建面、创建线,然后统统加到Object3D对象当中
const mesh = createMesh(coordinate, '#63bbd0', depth);
const line = createLine(coordinate, depth);
unit.add(mesh, ...line);
添加省市信息
还是一样的,在json文件当中我们可以拿到省市的经纬度数据和名称,在这里创建一个div通过CSS2DObject将div加到three当中,然后就是把经纬度坐标转换成xy坐标,其中y坐标是反的所以去一个反(在前面有提到过了),然后z也要+depth(地图的高度)这样lable标签也就完整的渲染到地图上了。
const createLabel = (name, point, depth) => {
const div = document.createElement('div');
div.style.color = '#000';
div.style.fontSize = '12px';
// 这个见仁见智哈,感觉加上整个都变模糊了
// div.style.textShadow = '1px 1px 2px #047cd6';
div.textContent = name;
const label = new CSS2DObject(div);
label.scale.set(0.01, 0.01, 0.01);
const [x, y] = offsetXY(point);
label.position.set(x, -y, depth);
return label;
};
添加纹理贴图
在实际开发过程当中,如果是要做那种山脉、地形的图我们就需要添加纹理到MeshStandardMaterial当中,上面只是简单的用颜色来控制展示,举一反三:上面的颜色值是固定的,定义一个获取随机颜色的方法替换掉固定的颜色值就是随机颜色的地图了。
这里还有一个小缺陷的,在这里加载纹理图片上来(图片可以随便来个),然后给纹理进行相对应的配置,直接给mesh加上,但是感觉纹理贴图贴在了边缘,没有贴到正面。这个也有点懵逼,不知道到底贴到了正面了没。正面看起来的效果倒也还行主要是
const mapTexture = new THREE.TextureLoader().load(mapTextureImage);
mapTexture.ratation = Math.PI;
mapTexture.wrapS = THREE.RepeatWrapping;
mapTexture.wrapT = THREE.RepeatWrapping;
mapTexture.repeat.set(1, 1);
mapTexture.needsUpdate = true;
const materialSettings = {
map: mapTexture,
bumpMap: mapTexture,
bumpScale: 0.01,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
};
const material = new THREE.MeshStandardMaterial(materialSettings);
添加图标
这个和前面添加label文字是一样的,我们可以拿到同样的经纬度坐标再转换成xy坐标,之后加个texture进行渲染
- Sprite是一个总是面朝着摄像机的平面,通常含有使用一个半透明的纹理
- 注意点:在遍历json里面的数据记得过滤掉不存在的数据(name为空的)
const createIcon = (point, depth) => {
const texture = new THREE.TextureLoader().load(CityImage);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true
});
const sprite = new THREE.Sprite(material);
const [x, y] = offsetXY(point);
// 因为这里地图、贴图也是有高度的,所以位置往上拉高点
sprite.position.set(x, -y, depth + 0.5);
sprite.renderOrder = 1;
return sprite;
};
监听点击
在这里由于所有的经纬度都转换成了xy坐标,我们就可以通过拿到鼠标点击的xy位置再去反推点击的是哪一个three对象
- Raycaster(光线投射):用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)
- setFromCamera:入参(在标准化设备坐标中鼠标的二维坐标、射线所来源的摄像机)
- intersectObjects:检查与射线相交的物体
- 到这一步再过滤掉线数据,之后看点击的类型是什么,这个里面也就有我们前面通过
unit.name = name;unit.adcode = adcode;
存的名称编码信息了
window.addEventListener('click', (event) => {
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster
.intersectObjects(map.children)
.filter((item) => item.object.type !== 'Line');
if (intersects.length > 0) {
// 点击市
if (intersects[0].object.type === 'Mesh') {
console.log(intersects[0]);
}
// 点击icon
if (intersects[0].object.type === 'Sprite') {
console.log(intersects[0]);
}
}
});
完整代码
<template>
<div id="map" class="w-full h-full"></div>
</template>
<script setup>
import * as THREE from 'three';
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js';
import {CSS2DObject, CSS2DRenderer} from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import {onMounted, ref} from 'vue';
import * as d3 from 'd3';
import ChinaData from '@/assets/mapJson/china.json';
import CityImage from '@/assets/image/city.png';
import mapTextureImage from '@/assets/image/map-texture.png';
const mapElement = ref();
// 创建场景
const scene = new THREE.Scene();
// 添加坐标轴
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);
// 如果用金属反光材质就用这个光
const colorLight = () => {
const ambientLight = new THREE.AmbientLight(0xd4e7fd, 4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xe8eaeb, 0.2);
directionalLight.position.set(0, 10, 5);
const directionalLight2 = directionalLight.clone();
directionalLight2.position.set(0, 10, -5);
const directionalLight3 = directionalLight.clone();
directionalLight3.position.set(5, 10, 0);
const directionalLight4 = directionalLight.clone();
directionalLight4.position.set(-5, 10, 0);
scene.add(directionalLight);
scene.add(directionalLight2);
scene.add(directionalLight3);
scene.add(directionalLight4);
};
// 创建相机
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 2000);
camera.position.y = 64;
camera.position.z = 64;
// 如果用地图纹理就用这个光
const mapTextureLight = () => {
const ambientLight = new THREE.AmbientLight('white', 0.5);
ambientLight.position.set(5, 10, 5);
scene.add(ambientLight);
const light = new THREE.DirectionalLight('#fff', 1);
light.position.set(5, 10, 5);
camera.add(light);
};
// 创建渲染器
const labelRenderer = new CSS2DRenderer();
const addRenderer = () => {
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.pointerEvents = 'none';
labelRenderer.setSize(window.innerWidth, window.innerHeight);
const ele = document.getElementById('map');
ele.appendChild(labelRenderer.domElement);
};
// 窗口大小变化监听器
const renderer = new THREE.WebGLRenderer({alpha: true});
renderer.setSize(window.innerWidth, window.innerHeight);
// 控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.update();
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
};
onMounted(() => {
addRenderer();
document.getElementById('map')?.appendChild(renderer.domElement);
animate();
mapTextureLight();
createMap(ChinaData);
});
// 矫正坐标
const offsetXY = d3.geoMercator();
const getRandomColor = () => {
return '#' + Math.floor(Math.random() * 16777215).toString(16);
};
// 根据省市的json数据创建地图
const createMap = (data) => {
const map = new THREE.Object3D();
const center = data.features[0].properties.centroid;
offsetXY.center(center).translate([0, 0]);
data.features.forEach((feature) => {
const unit = new THREE.Object3D();
const {centroid, center, name, adcode} = feature.properties;
const {coordinates, type} = feature.geometry;
const depth = 1;
// 绘制每个市的名称和图标
const label = createLabel(name, centroid || center || [0, 0], depth);
const icon = name ? createIcon(centroid || center || [0, 0], 1.11) : null;
coordinates.forEach((coordinate) => {
if (type === 'MultiPolygon') coordinate.forEach((item) => fn(item));
if (type === 'Polygon') fn(coordinate);
function fn(coordinate) {
// 添加自定义属性,点击的时候可以打印出来
unit.name = name;
unit.adcode = adcode;
// 绘制每个市的区域(传入颜色和深度)
const mesh = createMesh(coordinate, '#63bbd0', depth);
// 绘制每个市的边界
const line = createLine(coordinate, depth);
unit.add(mesh, ...line);
}
});
map.add(unit, label);
icon ? unit.add(icon) : null;
});
setCenter(map);
scene.add(map);
window.addEventListener('click', (event) => {
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster
.intersectObjects(map.children)
.filter((item) => item.object.type !== 'Line');
if (intersects.length > 0) {
// 点击市
if (intersects[0].object.type === 'Mesh') {
console.log(intersects[0]);
}
// 点击icon
if (intersects[0].object.type === 'Sprite') {
console.log(intersects[0]);
}
}
});
};
/**
* 绘制每个市的区域
* @param data 坐标数据
* @param color 颜色
* @param depth 深度
* */
const mapTexture = new THREE.TextureLoader().load(mapTextureImage);
mapTexture.ratation = Math.PI;
mapTexture.wrapS = THREE.RepeatWrapping;
mapTexture.wrapT = THREE.RepeatWrapping;
mapTexture.repeat.set(1, 1);
mapTexture.needsUpdate = true;
const createMesh = (data, color, depth) => {
const shape = new THREE.Shape();
data.forEach((item, idx) => {
const [x, y] = offsetXY(item);
if (idx === 0) shape.moveTo(x, -y);
else shape.lineTo(x, -y);
});
const extrudeSettings = {
depth: depth,
bevelEnabled: false
};
// 图片纹理贴图配置
const materialSettings = {
map: mapTexture,
bumpMap: mapTexture,
bumpScale: 0.01,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
};
// 金属反射配置
const materialSettings1 = {
color: color,
emissive: 0x000000,
roughness: 0.45,
metalness: 0.8,
transparent: true,
side: THREE.DoubleSide
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshStandardMaterial(materialSettings);
return new THREE.Mesh(geometry, material);
};
// 绘制每个市的边界
const createLine = (data, depth) => {
const points = [];
data.forEach((item) => {
const [x, y] = offsetXY(item);
points.push(new THREE.Vector3(x, -y, 0));
});
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
const upLineMaterial = new THREE.LineBasicMaterial({color: '#ffffff'});
const downLineMaterial = new THREE.LineBasicMaterial({color: '#ffffff'});
const upLine = new THREE.Line(lineGeometry, upLineMaterial);
const downLine = new THREE.Line(lineGeometry, downLineMaterial);
downLine.position.z = -0.1;
upLine.position.z = depth + 0.1;
return [upLine, downLine];
};
// 绘制每个市的名称
const createLabel = (name, point, depth) => {
const div = document.createElement('div');
div.style.color = '#fff';
div.style.fontSize = '14px';
div.textContent = name;
const label = new CSS2DObject(div);
label.scale.set(0.01, 0.01, 0.01);
const [x, y] = offsetXY(point);
label.position.set(x, -y, depth);
return label;
};
// 创建图标
const createIcon = (point, depth) => {
const texture = new THREE.TextureLoader().load(CityImage);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true
});
const sprite = new THREE.Sprite(material);
const [x, y] = offsetXY(point);
sprite.position.set(x, -y, depth + 0.5);
sprite.renderOrder = 1;
return sprite;
};
// 设置地图中心
const setCenter = (map) => {
map.rotation.x = -Math.PI / 2;
const box = new THREE.Box3().setFromObject(map);
const center = box.getCenter(new THREE.Vector3());
map.position.x = map.position.x - center.x;
map.position.z = map.position.z - center.z;
};
</script>