threejs进阶 通过json数据创建立体地图并实现下钻及返回

2,464 阅读8分钟

效果图

gif图比较大,请耐心等待...

2023-11-30 16.07.14.gif

地图下钻和返回demo使用到的技术栈 vite+typescript+threejs,从阿里云提供的 小工具中获取想要的数据,结构是一个json,一个地图坐标表,一个城市关系映射表,image.png 。我这里下载了浙江地图的数据,之后的案例都将以此数据为基础,截取一小段数据看一下结构

image.png

地图上所有的点坐标都放在arcs这个字段内。

相对历史文章,本文中新增了一些内容,比如不同尺寸模型始终在场景保持合适的位置,清空场景内容,layers的用法等。

文件目录

image.png

正文

创建地图

根据下载后的数据,用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 包围盒 获取到模型的sizecenter;上面代码出现的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())
}
image.png

下钻

这样我们就创建了一个基础的地图模型,之后的切换和下钻,都走以上的流程即可,所以做一个方法,能够重复调用,下钻无非就是通过点击模型,获取模型的信息,然后再通过城市关系映射的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(() => {

    })
2023-11-30 18.06.28.gif

每次更新相机都需要更新相机,不然没有效果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 ? '&gt;' : ''}
        `
    }
    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, 官网是这样描述的

.layers : 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)

		}
	}
}
2023-12-01 09.30.41.gif

源码

layers源码地址

地图下钻源码地址

历史文章

# threejs渲染高级感可视化涡轮模型

# 写一个高德地图巡航功能的小DEMO

# threejs 打造 world.ipanda.com 同款3D首页

外链引用

地图生成器小工具

释放资源手册原文

# 如何在窗口调整大小时保持场景比例不变?