如何在地图上加载大量模型

高德地图实现加载大量模型

介绍

有一天大佬跑过来跟我说:"目前我们在NS区部署了5万多根灯杆,你想想办法把全部的灯杆模型在NS区地图上全部show出来。不要搞那种放大了视角就四叉树聚合减少显示数量,聚焦到局部地区才显示百来个模型的投机取巧的操作。"

我听完马上震惊了,没想到大佬居然看穿了我的鬼蜮伎俩,并且提出了更高的要求。作为骄傲的工程师(不是)我哪能服软,马上安排。

经过几天断断续续的捣鼓研究,终于在大佬的PUA下搞定了这个事情。本文的内容主要就是对" 如何在高德地图上加载万级的gltf模型,仍然保持流畅的体验。"的实现方案做一个分享。

名词解释

网格: 在计算机图形中,网格是用一系列三角形来表示的 3D 对象,对应到three.js中就是Geometry类,Geometry包含了3D对象的顶点、边和面信息。

实现思路

常规做法

网上对加载gltf模型的常规的做法是直接Loader加载完模型后,使用Mesh.clone()批量复制模型。这种做法只能支持1000个以内的数量级,如果是面数复杂的模型估计上了100个都会有卡顿现象。

实例化网格做法

使用 InstancedMesh 来渲染大量具有相同几何体与材质、但具有不同世界变换的物体,特别适合制作森林、砂石、大量城市路灯、大量城市垃圾桶等场景 。 使用 InstancedMesh 可以有效地减少 draw call 的数量,从而提升应用的整体渲染性能。

这种方法有个限制,instanceMesh(geometry, material, instanceCount) 的第一个参数gemetry就要求模型是的geometry是1个单独的Mesh,没有子节点或兄弟节点。换言之如果你的gltf模型如果是3个独立的几何体组成,那么就需要实例化3个instanceMesh,且需要办法保证几何体的相对位置是固定的。

关于模型优化

无论采用哪种方案去实现最终效果的高性能,都需要渲染技术和原始模型的配合。再高效的渲染技术,如果遇到过于三角面数过于庞大的模型还是会有心无力,因此我们需要在保证模型外观过得去的情况下尽量优化模型。

对于模型的优化我们可以从以下几个方面入手,具体步骤再另外的文章分享:

  1. 合并模型网格
  2. 精简面数
  3. 模型展UV贴图
  4. 删除模型中看不见的内部面和无用边

经过这几步优化,我把原模型的三角形面数从9044个减少到1492个,文件体积从1060KB缩减到159KB。且保证了外观上没有太大的变化。

实现代码

下面我以"在地图上加载10000个模型"为例,讲解编码步骤。

1.预先加载模型,创建实例化网格

jsx 复制代码
function loadModel(){
	this.loader = new GLTFLoader()
	this.loader.load(sourceUrl, function (gltf) {
	    // 获取模型
	    const mesh = gltf.scene.children[0]
	   
	    // 缓存模型
	    _models.model = mesh
			_models.animations = gltf.animations
	    resolve()
	  },
	  function (xhr) {
	    console.log((xhr.loaded / xhr.total * 100) + '% loaded')
	  },
	  function (error) {
	    console.log('An error happened' + error)
	  }
	)
}

async function initMesh(){
	await loadModel()

	const { model, animations } = _models
	const geometry = model.geometry
	const material = model.material

  // 创建实例化网格
  const instanceCount = this._data.length
	// 3个参数分别是几何体、材质、数据量
	const instanceMesh = new THREE.InstancedMesh(geometry, material, instanceCount)
	this.scene.add(instanceMesh)
}

2.遍历数据,调整实例化网络

jsx 复制代码
const dummy = new THREE.Object3D()
const size = 4.0
dummy.scale.set(size, size, size)

const color = new THREE.Color()

for (let i = 0; i < instanceCount; i++) {
  const { id, modelId, altitude, angle, coords, lngLat } = this._data[i]
	// 调整空间位置
  dummy.position.set(coords[0], coords[1], (altitude === undefined ? 0 : altitude) + 0)
  dummy.updateMatrix()
  // 调整朝向
  if (angle !== undefined) {
    const fn = upAxis === 'x' ? 'rotateX' : (upAxis === 'z' ? 'rotateZ' : 'rotateY')
    dummy[fn](-angle / 180 * Math.PI)
  }
  // 将对dummy的调整复制到具体的网格中
  instanceMesh.setMatrixAt(i, dummy.matrix)
  // 给实例添加一个空的颜色,用于实现后面的拾取后颜色控制
	instanceMesh.setColorAt(i, color)
}
  1. 处理网格的鼠标拾取事件,鼠标拾取还是用Raycaster射线去实现
jsx 复制代码
// 整个地图容器添加鼠标事件
const t = this
this._raycaster = new THREE.Raycaster()
this.container.addEventListener('mousemove', _.debounce(function (event) {
  t.onRay(event)
}, 100, true))

onRay (event) {
  const { scene, camera } = this
  // 归一化坐标
  const pickPosition = this.setPickPosition(event)
  // 通过摄像机和鼠标位置更新射线
  this._raycaster.*setFromCamera*(pickPosition, camera)
  // 计算射线和场景的交集
  const intersects = this._raycaster.intersectObjects(scene.children, true)

  if (typeof this.onPicked === 'function') {
    this.onPicked.apply(this, [{ targets: intersects, event }])
  }
  return intersects
}

// 获取鼠标在three.js 中的归一化坐标
setPickPosition (event) {
  const pickPosition = { x: 0, y: 0 }
  const rect = this.container.getBoundingClientRect()
  // // 将鼠标位置归一化为设备坐标, x 和 y 方向的取值范围是 (-1 to +1)
  pickPosition.x = (event.clientX / rect.width) * 2 - 1
  pickPosition.y = (event.clientY / rect.height) * -2 + 1
  return pickPosition
}

// 拾取到物体时,处理拾取事件
onPicked ({ targets, event }) {
  let attrs = null
  // 选中状态颜色
  const color = new THREE.Color(0xff0000)

  const cMesh = this.getParentObject(targets[0], { name: this._impactName })

  if (cMesh?.isInstancedMesh) {
    const intersection = this._raycaster.intersectObject(cMesh, false)
    // 获取当前网格成员的编号,其实是它的生成序号
		// 通过这个编号就可以操作指定的网格成员
    const { instanceId } = intersection[0]
    // 把拾取对象变成指定颜色
    cMesh.setColorAt(instanceId, color)
    // 强制颜色实时更新,必须!
    cMesh.instanceColor.needsUpdate = true
    return
  }

总结

这个方案还能继续优化吗?答案是可以的,只要将模型的可见面数合并、不可见面删除,我们还可以进一步支持更大的数据量。这里试了下将灯杆精简为一个立方体之后加载5万多数据量的效果,看看左上角的帧率,性能是完全没问题的。

当然使用WebGL的方案要做到流畅体验,不仅仅需要技术上的优化,也需要性能良好(独立显卡)的终端设备的支持。这时候就可以理直气壮地跟老板说:加载模型上10万可以,得加钱(手动狗头)。

相关链接

THREE实例化网格

threejs.org/docs/index....

blender如何完全合并物体

www.bilibili.com/video/BV1Lm...

Blender展UV基础

www.bilibili.com/video/BV1vZ...

相关推荐
工业3D_大熊6 分钟前
3D可视化引擎HOOPS Luminate场景图详解:形状的创建、销毁与管理
java·c++·3d·docker·c#·制造·数据可视化
旧林8439 分钟前
第八章 利用CSS制作导航菜单
前端·css
yngsqq20 分钟前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing1 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风1 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟1 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾1 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧2 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm2 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7012 小时前
第8章利用CSS制作导航菜单
前端·css