介绍
来源:初始Threejs
效果展示
获取地图数据
主要通过阿里的DataV范围选取器来获得,但是数据更新于2021-5,而且仅供学习交流使用,要商用就自行考虑了。
datav.aliyun.com/portal/scho...
当然也可以去Bigemap、水经微图等专业软件去下载边界kml文件,并在GeoJson.io去转换成json格式,主要为了后续我们生成地图。
注意
如果需要自定义区域,需要自己手动去绘制,DataV、GeoJson.io以及其他专业软件都提供了绘制图形的工具,这方面就不展开细说了。
项目准备
这里为了快速实现效果,我直接在HTML内用CDN引入所需要的依赖,单个HTML就能完成所需要的效果,当然,为了工程化,实现逻辑大差不差,这个就需要自己去合理划分模块了。下面的代码块,我也会一一解释。
- 这里我用到了importmap去映射依赖关系,在后面script type=module标签中import导入importmap声明的模块。
- three:我们的主角。
- three/examples/jsm/:这里是为了引入OrbitControls,在工程化中就不需要设置映射。
- d3:主要用到的是其中墨卡托投影去转换GeoJson中的坐标,如果不想引入此依赖,也可以自己写公式转换。
- gsap:一个非常强大的动画库,说实话,作为一个前端,要是掌握了这个库,不需要使用像AE、AN等专业软件生产动画再转成Web端能够使用的格式,这个库就能在JS中设计出酷炫的动画。
xml
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"three/examples/jsm/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/",
"d3": "https://cdn.jsdelivr.net/npm/[email protected]/+esm",
"gsap": "https://cdn.jsdelivr.net/npm/[email protected]/+esm"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import * as d3 from 'd3';
import gsap from 'gsap';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
</script>
- 创建场景,ThreeJS基础三大要素,Scene、Renderer、Camera,为了更好的观察场景,我这里用到了OrbitControls去控制相机。注意:实例出来的renderer,需要不停调用renderer.render(scene, camera);去渲染整个场景。
- 这里因为要实现标签,用到CSS2DRenderer去渲染HTML标签,虽然可以用Sprite去绘制,但是那样还得写上不少代码去实现,CSS2DRenderer实现起来方便快捷。
- 在ThreeJS中,旋转是通过设置弧度,需要用Math.PI去计算弧度,其实ThreeJS有个工具函数THREE.MathUtils.degToRad()传递角度会转换成弧度,自己在其他教程或者资料没怎么看到过,所有在这里也提一嘴。
- 随便提醒一下在实例WebGLRenderer时,我设置了logarithmicDepthBuffer:true,是否使用对数缓冲,这个配置是为了防止网格模型距离太近,导致材质闪烁,也叫Z-Fighting,因为地图分区紧密挨着,可能会产生这种问题,所有这里我就开启了这一选项,能让渲染看起来达到了自己的预期,但是设置后会有额外的开销,需要注意。
ini
// 容器
const container = document.querySelector('.container');
// 场景
const scene = new THREE.Scene();
// 相机
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 45, 45);
// 渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
logarithmicDepthBuffer: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
// 2d渲染器
const css2Renderer = new CSS2DRenderer();
css2Renderer.setSize(window.innerWidth, window.innerHeight);
css2Renderer.domElement.style.position = 'absolute';
css2Renderer.domElement.style.top = '0px';
css2Renderer.domElement.style.left = '0px';
css2Renderer.domElement.style.pointerEvents = 'none';
container.appendChild(css2Renderer.domElement);
container.appendChild(renderer.domElement);
// 控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.maxDistance = 80;
controls.minDistance = 20;
controls.target.set(0, 0, 5);
controls.maxPolarAngle = THREE.MathUtils.degToRad(80);
// 渲染
const animate = function () {
requestAnimationFrame(animate);
renderer.render(scene, camera);
css2Renderer.render(scene, camera);
controls.update();
};
animate();
绘制地图
当上面步骤完成后,一个基础的场景就已经搭建好了,现在是如何绘制地图各个分区,并加上一定的交互。下面的代码有点长,需要花点时间。
- 这里我们就先初始化一些变量,为后面生成地图好使用,我也为每行代码添加了注释。
- d3.geoMercator().center([104.779307, 29.33924]).translate([0, 0, 0]);需要传递你当前地图的中心点经纬度,使地图位于世界中心位置。
ini
// 高度
const MAP_DEPTH = 0.2;
// 转换坐标函数
const projection = d3.geoMercator().center([104.779307, 29.33924]).translate([0, 0, 0]);
// 光线投射
const raycaster = new THREE.Raycaster();
// 材质加载器
const textureLoader = new THREE.TextureLoader();
// 区域网格列表
const provinceMeshList = [];
// 标签列表
const labelList = [];
// map Group容器,能统一规划区域
let map = null;
// 顶部材质
let topFaceMaterial = null;
// 侧面材质
let sideMaterial = null;
// 鼠标事件
let mouseEvent = null;
- 因为是在单HTML文件中实现效果,我这边需要用到请求去获取到json数据,在工程化的项目中就不需要,直接import。
scss
getMapData();
// 请求JSON数据
function getMapData() {
fetch('./map/zigong.json')
.then(response => response.json())
.then(data => {
setTexture();
setSunLight();
operationData(data);
})
.catch(error => console.error('Error fetching JSON:', error));
}
- 这一步是在设置灯光、材质,材质与灯光的关系太复杂,我也是摸摸皮毛,这里不过多阐述,以实现效果为主。
ini
function setSunLight() {
// 平行光1
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.9);
directionalLight1.position.set(0, 57, 33);
// 平行光2
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.6);
directionalLight2.position.set(-95, 28, -33);
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
scene.add(directionalLight1);
scene.add(directionalLight2);
scene.add(ambientLight);
}
//设置材质
function setTexture() {
const scale = 0.2;
const textureMap = textureLoader.load('/map/texture/gz-map.jpg');
const textureMapFx = textureLoader.load('/map/texture/gz-map-fx.jpg');
textureMap.wrapS = textureMapFx.wrapS = THREE.RepeatWrapping;
textureMap.wrapT = textureMapFx.wrapT = THREE.RepeatWrapping;
textureMap.flipY = textureMapFx.flipY = false;
textureMap.rotation = textureMapFx.rotation = THREE.MathUtils.degToRad(45);
textureMap.repeat.set(scale, scale);
textureMapFx.repeat.set(scale, scale);
topFaceMaterial = new THREE.MeshPhongMaterial({
map: textureMap,
color: 0xb3fffa,
combine: THREE.MultiplyOperation,
transparent: true,
opacity: 1,
});
sideMaterial = new THREE.MeshLambertMaterial({
color: 0x123024,
transparent: true,
opacity: 0.9,
});
}
- 终于来到了绘制各个区域这一步。
- 其实最主要的是通过THREE.Shape绘制一个二维的形状路径,再用THREE.ExtrudeGeometry去生成挤压缓冲几何体,这样就能绘制出每个区域网格模型。
- 各个区域的边界线的话,用的是THREE.BufferGeometry生成顶点信息,再用THREE.Line去生成网格。
- 但需要注意的是,GeoJSON有不同的type,比如MultiPolygon,包含多个路径,需要多次绘制当前的区域。
ini
/**
* 解析json数据,并绘制地图多边形
* @param {*} jsondata 地图数据
*/
function operationData(jsondata) {
map = new THREE.Group();
// geo信息
const features = jsondata.features;
features.forEach((feature) => {
// 单个区域 对象
const province = new THREE.Object3D();
// 地址
province.properties = feature.properties.name;
province.isHover = false;
// 多个情况
if (feature.geometry.type === "MultiPolygon") {
feature.geometry.coordinates.forEach((coordinate) => {
coordinate.forEach((rows) => {
const line = drawBoundary(rows);
const mesh = drawExtrudeMesh(rows);
province.add(line);
province.add(mesh);
provinceMeshList.push(mesh);
});
});
}
// 单个情况
if (feature.geometry.type === "Polygon") {
feature.geometry.coordinates.forEach((coordinate) => {
const line = drawBoundary(coordinate);
const mesh = drawExtrudeMesh(coordinate);
province.add(line);
province.add(mesh);
provinceMeshList.push(mesh);
});
}
const label = drawLabelText(feature);
labelList.push({ name: feature.properties.name, label });
province.add(label);
map.add(province);
});
map.position.set(0, 1, -1.5);
map.scale.set(10, 10, 10);
map.rotation.set(THREE.MathUtils.degToRad(-90), 0, THREE.MathUtils.degToRad(20));
scene.add(map);
setMouseEvent();
}
/**
* 画区域分界线
* @param {*} polygon 区域坐标点数组
* @returns 区域分界线
*/
function drawBoundary(polygon) {
const points = [];
for (let i = 0; i < polygon.length; i++) {
const [x, y] = projection(polygon[i]);
points.push(new THREE.Vector3(x, -y, 0));
}
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
const lineMaterial = new THREE.LineBasicMaterial({
color: 0xffffff,
linewidth: 2,
transparent: true,
depthTest: false,
});
const line = new THREE.Line(lineGeometry, lineMaterial);
line.translateZ(MAP_DEPTH + 0.001);
return line;
}
/**
* 绘制区域多边形
* @param {*} polygon 区域坐标点数组
* @returns 区域多边形
*/
function drawExtrudeMesh(polygon) {
const shape = new THREE.Shape();
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);
}
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: MAP_DEPTH,
bevelEnabled: false,
bevelSegments: 1,
bevelThickness: 0.1,
});
returnnew THREE.Mesh(geometry, [
topFaceMaterial,
sideMaterial,
]);
}
/**
* 绘制2d区域标签
* @param {*} province 区域对象
* @returns 区域标签
*/
function drawLabelText(province) {
const [x, y] = projection(province.properties.center);
const div = document.createElement('div');
div.innerHTML = province.properties.name;
div.style.padding = '4px 10px';
div.style.color = '#fff';
div.style.fontSize = '16px';
div.style.position = 'absolute';
div.style.backgroundColor = 'rgba(25,25,25,0.5)';
div.style.borderRadius = '5px';
const label = new CSS2DObject(div);
div.style.pointerEvents = 'none';
label.position.set(x, y, MAP_DEPTH + 0.05);
return label;
}
- 这一步是为了添加交互,也算是锦上添花。
- 值得注意的是射线拾取问题,拾取可能不精准问题,官方的案例是以canvas为整个屏幕宽高为基础去换算,如果当前的容器不是以整个屏幕,鼠标的坐标要减去当前容器的左边距离以及上边距离,这样的换算才能够准确。
- raycaster.intersectObjects需要接收检测和射线相交的一组物体,官方的例子是传递的scene.children,这会导致检测到很多物体,当然,这个方法还能传递第二个参数true或false,表示检测所有的后代,所以上面绘制地图的步骤时,保存了一份各个区域网格模型的列表,这样射线相交只会是其中物体。
- 还有一点,网格材质设置了transparent,射线会穿透物体,也会检测到很多物体,但是离屏幕越近的,在数组中越靠前,所以不想与后面的物体交互,只需要数组第一个就可以。
ini
function setMouseEvent() {
mouseEvent = handleEvent.bind(this);
container.addEventListener("mousemove", mouseEvent);
}
function removeMouseEvent() {
container.removeEventListener("mousemove", mouseEvent);
}
function handleEvent(e) {
if (map) {
let mouse = new THREE.Vector2();
let getBoundingClientRect = container.getBoundingClientRect();
let x = ((e.clientX - getBoundingClientRect.left) / getBoundingClientRect.width) * 2 - 1;
let y = -((e.clientY - getBoundingClientRect.top) / getBoundingClientRect.height) * 2 + 1;
mouse.x = x;
mouse.y = y;
raycaster.setFromCamera(mouse, camera);
let intersects = raycaster.intersectObjects(provinceMeshList, false);
if (intersects.length) {
let temp = intersects[0].object;
animation(temp.parent);
} else {
animation();
}
}
}
function animation(province) {
if (province) {
if (!province.isHover) {
province.isHover = true;
map.children.forEach((item) => {
if (item.properties === province.properties) {
gsap.to(province.position, {
z: 0.12,
duration: 0.6
})
} else {
resetAnimation(item);
}
})
}
} else {
resetAllAnimation();
}
}
function resetAnimation(province) {
gsap.to(province.position, {
z: 0,
duration: 0.6,
onComplete: () => {
province.isHover = false;
}
})
}
function resetAllAnimation() {
map.children.forEach((item) => {
resetAnimation(item);
})
}
优化问题
- 地图数据庞大问题,往往获取到的地图坐标点数量很多,对于设备会造成不小的负担,这里可以使用mapshaper去简化,还有就是QGIS这个免费开源软件也可以简化。 mapshaper.org/
- 深度问题,上面提到过材质闪烁,主要是物体间距离太过靠近,建议不要用logarithmicDepthBuffer,而是去调整物体间的距离,物体太小,调整距离可能不起作用,放大物体再调整距离。
最后
按照上面的步骤,基本可以实现一个ThreeJS的3D区域地图,这其中,自己也踩了不少的坑😭😭😭😭😭😭
希望通过这篇文章,能减小大家踩坑几率吧。