threejs——商场楼宇室内导航系统

8,981 阅读6分钟

源码

源码下载地址

在线体验地址

视频说明地址 西瓜视频

技术栈

  • webpack 4.39.2
  • nodejs v10.9.0
  • threejs 0.107.0

前言

本文通过一个简单的3d导航系统引申出一些关于threejs开发过程中遇到的问题,理论比较多,可能有些枯燥,每一个开发中需要注意的点,也有性能优化的小点,都会以标题的方式标注出来。希望能对你有所帮助,也是我对自己的一些小总结,小笔记。

效果图

导航效果图

2024-05-20 10.54.16.gif

查看信息

2024-05-20 10.57.19.gif

正文

基础场景这里就不赘述了

数据加载

因为是一个本地项目,所以我把所有的数据都存放到对应的json文件中,再自定义一个fetch方法,用来请求数据,以下是楼层文件信息,店铺信息就不展示了,结构都是差不多的。这里简单陈述一下,方便之后文章的理解。

[{
    "url": "./static/model/F1.gltf",
    "name": "F1",
    "id": 1
},
{
    "url": "./static/model/F1.gltf",
    "name": "F2",
    "id": 2
},
{
    "url": "./static/model/F1.gltf",
    "name": "F3",
    "id": 3
}
]

异步请求数据代码

export function GetFloor(url:string) {
    return new Promise(
        function (resolve, reject) {
            window.fetch(url).then(function (data) {
                resolve(data.json())
            })
        }
    )
}

通过异步请求json文件,得到json文件内部数据,

await GetFloor('./static/json/floor.json').then((res: any) => {
    this.floorData = res
})

加载模型

楼层文件是我用c4d制作的一个 obj文件,由于c4d目前对gltf输入和输出并不友好,所以作为前端,有前端的解决方式,用Obj2gltf的转换库git地址,开箱即用,当然如果你身边有3d设计师,可以让设计师用Blender将obj转成gltf的格式。

模型选型

这里稍微讲一下文件格式选型,文中用到的是gltf格式,gltf可以以最小的体积表现更多的内容,包括贴图,着色器等等,而且传输性能相对高效。作为通用模型格式,设计师也能更好的交付自己的模型,或者与设计师之间的协作也更方便。个人作为一个野生前端开发,没有任何支援,只能自己瞎搞搞

处理楼层模型

加载到楼层模型后 需要将楼层展示到场景中,但是具体展示多少,怎么才能全部展示出来,这就需要计算相机的距离,我这里偷懒了,所有的楼层都是用的同一个模型,所以计算一个楼层的尺寸就知道其他的尺寸了,如果不是相同的楼层就需要计算所有楼层的尺寸并找到一个最大值,

if (this.loadFloorIndex === 0) {
    // 根据第一层计算相机位置,将所有楼层适配显示到屏幕
    const ground = scene.getObjectByName('floor')
    const size = new THREE.Vector3()
    this.$getBox.getbox(ground).getSize(size)
    // 两条直角边
    const a = size.x
    const b = size.z
    // 求斜边
    const c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2))
    this.camera.position.set(c + Math.min(datalength, 4) * 50, Math.min(helfLength, 4) * this.floorHeight, c + Math.min(datalength, 4) * 50)
}

上述代码中 helfLength是楼层数量/2,作为一个根据楼层数量改变的变量,将控制相机的位置,从而让所有的楼层都展示在场景中

3层.jpg 5层.jpg

绑定店铺

店铺数据:

{
    "id": 11,
    "floorid": 1,
    "name": "兰巴赫",
    "coordinates": "80,37",
    "doorcoordinates": "",
    "floorname": "F1",
    "format": "餐饮"
},

coordinates字段作为模型和数据之间的标识,是通过box3.getCenter获取。

const cp = Math.floor(v3.x) + ',' + Math.floor(v3.z)

通过判断店铺所在楼层和楼层的center数据将模型和数据绑定起来

member.coordinates === cp && member.floorid === floorId

创建店铺名称

export function createText(name, vector) {
  var text = document.createElement("div");
  text.className = "member-name";

  text.textContent = name
  let css2d: any = new CSS2DObject(text);
  css2d.position.copy(vector);
  css2d.name = name
  css2d.isType = '2'
  return css2d
}

店铺名称创建使用的是 # CSS2DRenderer,可以将html元素映射到3d世界,其实不是添加到3d世界,而是通过3d场景的坐标和元素的csstransform属性对应,是单独的一个图层,这样就会导致一个问题,3d图层永远在css2d元素图层的下面,所以这里需要进行一点点的处理。

动态显示隐藏2d元素

隐藏名称.gif

基于射线的原理做一个点到点的射线检测


export function pointRay(star, end, children) {
  // createLine(star, end);
  let nstar = star.clone(); // 克隆一个新的位置信息,这样不会影响传入的三维向量的值
  let nend = end.clone().sub(nstar).normalize(); // 克隆一个新的位置信息,这样不会影响传入的三维向量的值
  var raycaster = new THREE.Raycaster(nstar, nend); // 创建一个正向射线
  var intersects = raycaster.intersectObjects(
    children,
    true
  );
  // console.log(intersects[0].point.length())
  // console.log(end.length())
  let jclang = 0
  let textlang = 0
  if (intersects.length != 0) {
    jclang = star.distanceTo(intersects[0].point)
    textlang = star.distanceTo(end)
  }
  return jclang < textlang;
}

从相机发射一条射线到2d元素,将楼层信息作为检测目标,如果在这条射线上检测到楼层的存在了,说明当前楼层挡住了目标2d元素,这里用到的检测是每个楼层的底板部分,忽略店铺模块,这是出于性能考虑的。

空气墙

射线检测会检测每一个目标模型的顶点信息,当顶点信息过多,性能肯定会有损耗,比如在熊猫基地这篇文章中用到的地球模型,就是特别复杂的模型,在不得已的情况,2d的射线检测,目标模型就不能使用这个模型,在代码中,使用了一个类似大小的球体作为检测目标,像这种球体啊,立方体啊,都比较好找替代的,如果是非常规模型,就需要设计师提供对应的空气墙,毕竟性能提升是3d开发绕不过的话题。

查看信息

在页面右上角有一个操作模板,是区分功能的,可以查看店铺信息、楼层信息、室内导航等等。 同样是使用射线检测,点击时根据选择的的右上角功能进行不同的操作。

var handleClick = function (event, _this) {
  if ((window as any).unRay) {
    return
  }
  _this.mouse.x = (event.clientX / document.body.offsetWidth) * 2 - 1;
  _this.mouse.y = -(event.clientY / document.body.offsetHeight) * 2 + 1;

  if (event.touches) {
    _this.mouse.x = (event.touches[0].pageX / window.innerWidth) * 2 - 1;
    _this.mouse.y = -(event.touches[0].pageY / window.innerHeight) * 2 + 1;
  } else {
    _this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    _this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  }
  var raycaster = new THREE.Raycaster();
  raycaster.setFromCamera(_this.mouse, _this.camera);
  var raylist = raycaster.intersectObjects(_this.floorGroup.children, true);
  if (raylist[0]) {
    let obj = raylist[0].object

    let handleState = (window as any).$handleState
    // 处理轨迹
    if (handleState === 'trail') {
      handleTrail(obj, _this)
    } else if (handleState === 'floor') {
      let obj = raylist[0].object.parent
      handleFloor(_this, obj)
    } else if (handleState === 'member') {
      let obj = raylist[0].object
      handleMember(_this, obj)
    }
  }
}

使用touches来兼容移动端,确保在移动端也可以进行互动,

楼梯

电梯数据

{ "id": 101, "floorid": 1, "name": "麦当劳扶梯", "coordinates": "-1,0", "doorcoordinates": "", "floorname": "F1", "type": 0 },

同理,根据box3.getCenter获取中心点坐标,并根据数据进行绑定。

导航

室内导航的流程相当复杂而又精密。首先,系统需要准确识别用户当前的位置,并确定用户所要前往的目的地是否与其在同一楼层。如果目的地位于同一楼层,那么导航系统将直接指引用户前往目的地;但如果目的地位于不同楼层,系统将面临更复杂的挑战。

在这种情况下,系统首先会检索并选取离用户当前位置最近的电梯,以便跨越楼层。选择电梯时,需要考虑多种因素,如电梯当前位置、运行状态以及用户所需楼层等。一旦电梯选定,系统将指导用户前往该电梯并搭乘。

接下来是电梯内的移动过程。系统需要准确控制电梯的运行方向,确保用户能够顺利抵达目的楼层。在到达目的楼层后,用户需要再次确定自己的位置并根据导航系统的指引,前往具体的目的地。

因此,整个导航过程可划分为三个关键阶段:跨楼层定位、电梯选择与移动、最终目的地导航。每个阶段都需要系统精确的定位和智能的决策,以确保用户能够高效、准确地到达目的地。

 if (mySelfFloorId === memberFloorId) {
      trail = gettrail(mySelfPosition, obj)
      if (trail.length === 0) {
        // 需要记录进店前的终点,作为下一次出发的起点,
        // 代码需要重新整理
        alert('你无路可逃')
        return
      }
      let memberCp = v2tov3(obj.cp, obj.parent.getWorldPosition().y)
      let endToPcTrail = getEndToPc(trail[trail.length - 1], memberCp)
      let allTrail = trail.concat(endToPcTrail)
      Drawline(_this, allTrail)
      // var geometry = new THREE.BufferGeometry().setFromPoints(allTrail);
      // var material = new THREE.LineBasicMaterial({
      //   color: '#f14f54'
      // });
      // var line = new THREE.Line(geometry, material);
      // _this.scene.add(line)
      headerMove(allTrail, _this.mySelf)

    } else {
      // 找到最近的电梯
      // 获取头像所在楼层
      const headerFloor = _this.scene.getObjectByProperty('floorId', _this.mySelf.mySelfFloorId)
      let minEle = null
      let minLen = 9999
      let eleCp = new Vector3()
      let eleVector3 = new Vector3()
      let floorPosition = new THREE.Vector3() as any
      headerFloor.children.forEach((mesh, index) => {
        // 找到所有电梯
        if (mesh.elevatorId) {
          // 获取电梯中心点
          eleCp = v2tov3(mesh.cp, mesh.getWorldPosition(new Vector3()).y)
          let len = eleCp.distanceTo(mySelfPosition)
          if (minLen > len) {
            minLen = len
            minEle = mesh
            mesh.getWorldPosition(floorPosition)
            eleVector3 = eleCp
          }
        }
      })
      if (minEle) {
        // 第一阶段轨迹
        let trailFirst = gettrail(mySelfPosition, minEle)
        // 获取第二段轨迹(上电梯)
        let firstEleFloor = eleVector3

        let endEleFloor = new THREE.Vector3()
        // 获取重点楼层的Y
        obj.parent.getWorldPosition(endEleFloor)
        _this.mySelf.mySelfFloorId = obj.parent.floorId
        // 获取到第二段结束的点
        let endPoint = firstEleFloor.clone().setY(endEleFloor.y)

        let trailSecond = getEndToPc(firstEleFloor, endPoint)



        let points = obj.parent.userData.trailPoint
        // 寻找距离电梯上来后的最近可以行走的点
        let minPoint = null
        let minLen = 99999
        for (let i = 0; i < points.length; i++) {
          let p = points[i]
          let len = endPoint.distanceTo(p)
          if (len < minLen) {
            minLen = len
            minPoint = p
          }
        }
        // 获取电梯到店铺最近点的
        let trailThird = gettrail(minPoint, obj)
        // let lastTrail 
        // 获取店铺最近点到店铺中心的路径
        let lastTrail = getEndToPc(trailThird[trailThird.length - 1], v2tov3(obj.cp, obj.getWorldPosition(new THREE.Vector3()).y))

        if (trailFirst.length === 0 || trailSecond === 0 || trailThird.length === 0 || lastTrail.length === 0) {
          alert('你无路可逃')
          return
        }

        let allTrail = trailFirst.concat(trailSecond).concat(trailThird).concat(lastTrail)
        headerMove(allTrail, _this.mySelf)
        Drawline(_this, allTrail)
        // var geometry = new THREE.BufferGeometry().setFromPoints(allTrail);
        // var material = new THREE.LineBasicMaterial({
        //   color: '#f14f54'
        // });
        // var line = new THREE.Line(geometry, material);
        // _this.scene.add(line)
      }
    }

代码方法说明

  • gettrail(): 用于计算两点之间的路径。
  • v2tov3(): 将二维向量转换为三维向量。
  • getEndToPc(): 获取从起点到终点的路径。
  • Drawline(): 绘制导航路径。
  • headerMove(): 移动用户到目标位置。
  • getObjectByProperty(): 根据属性获取场景中的对象。
  • getWorldPosition(): 获取对象的世界坐标。
  • distanceTo(): 计算两点之间的距离。

可扩展内容

  1. 在行动路线更新时,可以将沿途的店铺列出来
  2. 集成到小程序中,通过扫码获取用户具体位置,和商场静态指示牌效果一样。
  3. 商铺入驻,点击商铺,通过图标分析客流量或用户画像
  4. 在移动端配合高德地图实时导航(目前只是规划路线)