源码
视频说明地址 西瓜视频
技术栈
- webpack 4.39.2
- nodejs v10.9.0
- threejs 0.107.0
前言
本文通过一个简单的3d导航系统引申出一些关于threejs开发过程中遇到的问题,理论比较多,可能有些枯燥,每一个开发中需要注意的点,也有性能优化的小点,都会以标题的方式标注出来。希望能对你有所帮助,也是我对自己的一些小总结,小笔记。
效果图
导航效果图
查看信息
正文
基础场景这里就不赘述了
数据加载
因为是一个本地项目,所以我把所有的数据都存放到对应的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,作为一个根据楼层数量改变的变量,将控制相机的位置,从而让所有的楼层都展示在场景中
绑定店铺
店铺数据:
{
"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元素
基于射线的原理做一个点到点的射线检测
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(): 计算两点之间的距离。
可扩展内容
- 在行动路线更新时,可以将沿途的店铺列出来
- 集成到小程序中,通过扫码获取用户具体位置,和商场静态指示牌效果一样。
- 商铺入驻,点击商铺,通过图标分析客流量或用户画像
- 在移动端配合高德地图实时导航(目前只是规划路线)