效果图
gif图比较大,请耐心等待...
地图下钻和返回demo使用到的技术栈 vite
+typescript
+threejs
,从阿里云提供的 小工具中获取想要的数据,结构是一个json,一个地图坐标表,一个城市关系映射表,
。我这里下载了浙江地图的数据,之后的案例都将以此数据为基础,截取一小段数据看一下结构
地图上所有的点坐标都放在arcs
这个字段内。
相对历史文章,本文中新增了一些内容,比如不同尺寸模型始终在场景保持合适的位置
,清空场景内容
,layers的用法
等。
文件目录
正文
创建地图
根据下载后的数据,用axios.get请求到本地,并解析数据,js基础我这里就不阐述了,各自发挥,拿到数据以后,使用three.js提供的# 挤压缓冲几何体(ExtrudeGeometry)
,挤压出厚度
// 新建一个形状实例
const shape = new THREE.Shape();
const points: THREE.Vector3[] = [];
arcs.forEach((v2Arr: number[], index: number) => {
if (index === 0) {
shape.moveTo(v2Arr[0], v2Arr[1]);
} else {
shape.lineTo(v2Arr[0], v2Arr[1]);
}
points.push(new THREE.Vector3(v2Arr[0], v2Arr[1], 0))
})
// 通过形状和挤压模型的信息,创建一个挤压模型
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const mesh = new THREE.Mesh(geometry, material);
将得到的mesh网格放到一个group中,并将这个group居中,接下来创建一个levelGroup
存放当前城市模型和名称
城市名称
const label = this.track(this.drawAreaName(info.objects.collection.geometries[0].properties, new THREE.Color(Math.random() * 0xffffff)))
const city = this.levelGroup.getObjectByName(mapInfo.name)
if (city) {
const pos = new THREE.Vector3()
city?.getWorldPosition(pos)
const { center } = this.getBoxInfo(city)
label.position.copy(center)
labelGroup.add(label)
}
getBoxInfo
主要作用就是通过 # Box3 包围盒 获取到模型的size
和center
;上面代码出现的this.track
在后续内容会明确阐述。
getBoxInfo
getBoxInfo(mesh) {
// 创建一个包围盒
const box3 = new THREE.Box3()
// 添加包围盒对象
box3.expandByObject(mesh)
const size = new THREE.Vector3()
const center = new THREE.Vector3()
// 通过box3提供的api获取到center和size
box3.getCenter(center)
box3.getSize(size)
return {
size, center
}
}
模型的居中和文字的居中原理一样的,都是通过获取模型的中心点,将中心点赋值给模型,作为模型的position
,不过模型以自身的center居中需要取反一下
toSceneCenter(mesh) {
const { center, size } = this.getBoxInfo(mesh)
mesh.position.copy(center.negate())
}
下钻
这样我们就创建了一个基础的地图模型,之后的切换和下钻,都走以上的流程即可,所以做一个方法,能够重复调用,下钻无非就是通过点击模型,获取模型的信息,然后再通过城市关系映射的json文件找到它的下级城区,拿杭州举例,绘制浙江地图,由杭州、台州、宁波等城市组成,那么点击的射线检测目标就是这些城市,在点击的时候,获取treeid,清除原内容。重新绘制由各个区组成的杭州城市地图
async getChildArcs(treeID: string, level: number) {
// 通过点击时模型的treeid获取下级城市数据
let data = await getArcsByTreeID(treeID)
// 如果没有数据,则返回false
if (!data) return false
this.level = level
// 渲染历史的dom节点
this.renderHistory(data)
this.thatCityData = data
// 完美清空场景内容
this.clear()
const child = data.children
let keys = Object.keys(child)
let arcsInfo = keys.map((key) => child[key].payload)
// 绘制城市数据
this.drawCity(arcsInfo)
}
射线检测
在创建场景以后,将window的click时间监听一下,window.addEventListener("click", this.rayFunction.bind(this));
rayFunction
rayFunction(event) {
let mouse = new THREE.Vector2(); //鼠标位置
// 创建射线检测实例
var raycaster = new THREE.Raycaster();
mouse.x = (event.clientX / document.body.offsetWidth) * 2 - 1;
mouse.y = -(event.clientY / document.body.offsetHeight) * 2 + 1;
// 设置一条从相机位置到鼠标位置的检测射线
raycaster.setFromCamera(mouse, this.camera);
const rallyist = raycaster.intersectObjects(this.levelGroup.children, true);
if (this.controlsMoveFlag) {
// 进行下一步绘制
this.toNextLevel(rallyist)
}
}
需要说明的是controlsMoveFlag
这个变量,由于监听的是click事件,以click事件的执行顺序看,移动鼠标再抬起鼠标也会执行,那跟控制器的功能就冲突了,我并不想在移动镜头的时候触发射线检测,所以要加一个变量,来识别在按下和抬起鼠标的时候,鼠标有没有移动过,代码里使用的是控制器的start和end记录镜头位置来判断的
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
// 开始的时候记录摄像头位置
this.controls.addEventListener('start', () => {
this.controlsStartPos.copy(this.camera.position)
})
// 结束的时候计算当前镜头位置和start时的镜头位置距离,如果为0则表示没移动过镜头(鼠标)
this.controls.addEventListener('end', () => {
this.controlsMoveFlag = this.controlsStartPos.distanceToSquared(this.camera.position) === 0
})
清空内容
ResourceTracker.ts
方法导出一个类,包含过滤资源,收集资源,销毁资源等方法,源码比较多,我就不贴了,具体查看 ResourceTracker.ts
里面有少量备注,如果阅读起来费劲,可以看threejs提供的 手册原文
实例化ResourceTracker,并存一下收集资源的方法,以供后面使用。
this.resMgr = new ResourceTracker();
this.track = this.resMgr.track.bind(this.resMgr);
拿创建名称举例,将创建好的名称通过this.track
收集到this.regMgr
中
const label = this.track(this.drawAreaName(info.objects.collection.geometries[0].properties, new THREE.Color(Math.random() * 0xffffff)))
将所有需要销毁元素都收集起来后,在进入下一级时,对现有元素销毁,并创建新的模型
clear() {
this.resMgr && this.resMgr.dispose();
this.levelGroup = this.track(new THREE.Group())
this.scene.add(this.levelGroup)
}
销毁模型后,要重新创建一个levelGroup载体。
改变camera.fov
以上,加载模型,点击下钻,清空原有内容,再创建新内容,这几步都做完了,进行下一步的时候,会发现一个问题,模型尺寸是不一致的,导致下钻到下一级的时候,模型基本看不到了,所以这时候要改变一下camera,可以让camera的position改一下,也可以修改fov,就像照相机一样,在照相的时候,如果目标距离你很远,你又不方便靠近的时候,就需要变焦相机,改变焦距即可,那么three提供的透视相机也有相似操作fov 摄像机视锥体垂直视野角度
,视野越大,能看到的东西就越多,也越远,通过这个原理,可以实现不同尺寸的模型都可以合适的出现在camera,看起来模型是一样大的,其实只是改变的观看的角度
以上理论灵感来自 # 如何在窗口调整大小时保持场景比例不变?,文中解决的是不同的屏幕尺寸保持模型在相机的合适位置,跟文中不同的是,咱们是模型不同,改变fov
choseCamera(mesh) {
if (this.lastMesh) {
// 获取上次加载的模型
const { size: lastSize } = this.getBoxInfo(this.lastMesh);
// 本次模型尺寸
const { size } = this.getBoxInfo(mesh);
const fov = this.camera.fov
let yScale = 1
if(size.length<lastSize.length) {
yScale = lastSize.y / size.y
} else {
yScale = size.y / lastSize.y
}
this.camera.fov = fov * yScale
this.camera.updateProjectionMatrix();
}
}
动画
依然是使用tween来实现动画的效果
new this._TWEEN.Tween({ fov })
.to({ fov: fov * yScale }, 1000)
.start()
.onUpdate((value) => {
console.log(value);
this.camera.fov = value.fov
this.camera.updateProjectionMatrix();
})
.onComplete(() => {
})
每次更新相机都需要更新相机,不然没有效果updateProjectionMatrix
历史记录
历史记录相对于以上的工作,相对简单一点,如果各位用的是框架,可以跳过,我用js操作dom方式写的,所以比较不美观,在getChildArcs
方法中,创建下级城市模型的时候,将信息收集起来,并渲染一个dom出来
renderHistory(info) {
this.history[this.level] = info
const keys = Object.keys(this.history)
let historyDomHTML = ``
for (let i = 1; i <= this.level; i++) {
const key = i
historyDomHTML += `
<p data-cityInfo='${JSON.stringify({
level: key,
info: this.history[key]
})
}'>${this.history[key].name}</p>${i < this.level ? '>' : ''}
`
}
let dom = document.querySelector('.history-dom')
if (dom) dom.innerHTML = historyDomHTML
}
通过事件代理,监听.history-dom
的点击事件,获取刚才代码中存的data-cityInfo
数据,并获取到level
和模型信息,
再执行下钻时的操作;
const historyDom = document.querySelector('.history-dom')
if (historyDom) {
historyDom.addEventListener('click', (event: any) => {
const tag = event?.target
if (tag?.nodeName === 'P') {
const cityInfoStr = tag.getAttribute('data-cityInfo')
const cityInfo = JSON.parse(cityInfoStr)
if (cityInfo.level !== drawCity.level) {
drawCity.getChildArcs(cityInfo.info.treeID, cityInfo.level)
}
}
})
}
优化方案——理论
优化的点在于每次返回都重绘一遍之前绘制过的上级市辖区,demo中的模型相对小一些,重绘起来没什么压力,如果在模型上加上贴图,发光,边缘线等因素,那重绘起来就费劲了,这时候就需要用到object3D的一个属性layers
,
官网是这样描述的
物体的层级关系。 物体只有和一个正在使用的Camera至少在同一个层时才可见。当使用Raycaster进行射线检测的时候此项属性可以用于过滤不参与检测的物体.
那么咱们就可以运用这个原理,将不同level的市辖区,设置不同的layers,
以杭州为例,当前渲染的是杭州的模型,layers.set(2)
,点击余杭区,那么出现的将是余杭区的地图,layers.set(3)
,camera也同样设置为3,当返回上一级的时候,杭州的模型还没删除,只是不可见,那么再设置camera.layers.set(2),就可以无缝衔接的切换到上一级了,下面我做了一个小的demo,将以上理论实现一下
// 立方体 THREE.Layers.set(1)
const geometry1 = new THREE.BoxGeometry(1, 1, 1);
const material1 = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry1, material1);
cube.layers.set(1)
group1.add(cube);
// 胶囊 THREE.Layers.set(2)
const geometry2 = new THREE.CapsuleGeometry(1, 1, 4, 8);
const material2 = new THREE.MeshLambertMaterial({ color: 0xffff00 });
const capsule = new THREE.Mesh(geometry2, material2);
capsule.layers.set(2)
group2.add(capsule);
// 圆锥 THREE.Layers.set(3)
const geometry3 = new THREE.ConeGeometry(5, 20, 32);
const material3 = new THREE.MeshLambertMaterial({ color: 0xff00ff });
const cone = new THREE.Mesh(geometry3, material3);
cone.layers.set(3)
group3.add(cone);
// 默认相机layers为1
T.camera.layers.set(1)
// 用的是Lambert网格材质所以需要灯光的加持,light同时也要修改
T.light.layers.set(1)
const btns = document.querySelectorAll('.btns button')
if (btns) {
for (let i = 0; i < btns.length; i++) {
const btn = btns[i]
btn.onclick = () => {
// 点击按钮修改相机的layers
console.log(i + 1);
T.camera.layers.set(i + 1)
T.light.layers.set(i + 1)
}
}
}
源码
历史文章
# threejs 打造 world.ipanda.com 同款3D首页