这都4202年了,你写的大屏还是平平无奇?
前言
演示地址
随着物联网的火爆,和ai的快速崛起,传统行业在AI赋能的加持下,都在改头换面,拿工地举例,从工人进门开始就有智能打卡机,施工时佩戴的安全帽结合gps和其他传感器能够及时了解人员是否佩戴,在视频监控区域也可以通过ai算法检查工人是否佩戴安全帽;各种传感器安装到机械设备上,能够实时监控设备的数据,下面文章内容就是基于工地的塔吊设备衍生出的一个智慧大屏功能,先上效果图,
欢迎点赞收藏关注
效果图
操作:
图例:
视频演示:
技术栈
- vite 4.3.2
- three 0.161.0
- node v18.19.0
正文
大屏由几个部分组成,最上部是标题,左侧是设备信息,右侧是图例,方向预览和表单,下面主要写的内容是3d部分操作,不涉及设备信息表头这些辅助类的模块
文件结构
为了阅读代码方便,每一个功能都单独抽离到ts文件中,参考以下文件目录
(前摇真长啊~)
基础场景
场景
基础场景在文件scene.ts中主要包括 # THREE.Scene场景,用于承载所有3d部分的内容。
/** 场景 */
scene = new THREE.Scene();
scene.background = new THREE.Color('#000000');
scene.fog = new THREE.Fog(new THREE.Color("rgba(111, 114, 130, 0.5)"), 8000, 12000)
镜头
镜头使用的是PerspectiveCamera 透视相机,也是常规用的相机,没什么说的,这里要说一下camera.layers.enableAll(),项目中有图例的功能,主要api就是 camera.layers,enableAll启用所有图层,
enable 启用某个图层,disableAll 隐藏所有图层,toggle禁用某个图层
透视相机layers的api是从基类 Object3D继承来的,所以理论上基于Object类的物体都支持这个功能。
/** 相机 */
camera = new THREE.PerspectiveCamera(60, width / height, 0.2, 2000000);
camera.position.copy(cameraPos);
camera.layers.enableAll()
后续在模型中也设置相同的layers,在后面的处理模型和图例显示隐藏会具体提到
渲染器
代码中渲染器写了3种,但实际中只用到了两种THREE.WebGLRenderer和CSS2DRenderer
webglrenderer
renderer = new THREE.WebGLRenderer({
canvas,
precision: 'highp',
antialias: true,
powerPreference: 'high-performance',
logarithmicDepthBuffer: true,
});
renderer.toneMappingExposure = 0.6
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.needsUpdate = true
renderer.shadowMap.autoUpdate = true
构建时的参数除了canvas,其他都是调整性能的,代码里调整的都是最高性能,precision着色器精度,antialias抗锯齿,powerPreference以怎样的配置进行渲染,因为选用的渲染器是webglrenderer,所以这个api你也可以在webgl的源码中查看到
// 可选值
enum WebGLPowerPreference { "default", "low-power", "high-performance" };
// 默认值
WebGLPowerPreference powerPreference = "default";
这些值可以调整为其他可选值用来适配不同的设备性能
setPixelRatio是设置像素比的,用来防止渲染模糊的,这是设置成0.2的效果,如果你想做一些特殊的效果,可以根据不同的数值调整
shadowMap 是用来渲染阴影的,如果设备的性能不行,也可以不渲染,或者渲染低质量的阴影
CSS2DRenderer
labelRenderer = new CSS2DRenderer({
element: css2dDiv
});
labelRenderer.domElement.classList.add('css2d')
labelRenderer.setSize(width, height);
CSS2DRenderer是用来显示创建的css2DObject的,其实就是通过css的transform的属性将html的元素放置在不同位置,大概像这样
transform: translate(-50%, -50%) translate(476.36px, 228.269px);
而并不是将html元素放到3d世界中,3d渲染元素是canvas,而2d场景是另一个html元素内,如果想做遮挡,需要一些其他手段,比如做防碰撞功能时候需要两个以上塔机,而前面的塔机遮挡了后面的塔机,但是他们的名字标签都展示出来了,这时候需要处理后面被遮挡的塔机名称标签隐藏
具体做法可以参考我之前的文章threejs 打造 world.ipanda.com 同款3D首页,里面有详细的解释
控制器
controls = new OrbitControls(camera, renderer.domElement);
// 控制器限制
// 水平转角
controls.minAzimuthAngle = Math.PI * 0.25;
controls.maxAzimuthAngle = Math.PI * 0.75;
// 垂直转角
controls.minPolarAngle = Math.PI * 0.25;
controls.maxPolarAngle = Math.PI * 0.6;
// controls.maxDistance = 2500
// controls.minDistance = 500
controls.target.set(-144, 814, 0);
controls.addEventListener('start', () => {
controlsStartPos.copy(camera.position)
})
controls.addEventListener('end', () => {
controlsMoveFlag = controlsStartPos.distanceToSquared(camera.position) === 0
})
轨道控制器OrbitControls用于操作场景,旋转缩放,都可以单独进行限制,代码里将水平转角和垂直转角进行了限制,让用户只能在某个范围内进行操作,注释掉的代码maxDistance是控制缩放深度,你也可以通过各种enabled属性限制控制器的禁用和启用
控制器的target是用来修改场景位置的,和相机的position不同,这个属性是可以直接修改场景的中心位置,代码中对此进行了设置,如果不设置则是下面的效果
场景的中心在屏幕正中央,导致模型并未展示完全,并且下面还都是空着的,在使用的过程中,根据需要调整。
处理模型
模型格式是gltf的,那么加载器就选择# GLTFLoader,在loader.ts中封装了loadGltf方法,返回GLTF类型,gltf中的scene就是加载后的模型信息
const gltfLoader = new GLTFLoader();
export function loadGltf(url: string) {
return new Promise<GLTF>((resolve, reject) => {
gltfLoader.load(url, function (gltf: GLTF) {
console.log(gltf);
resolve(gltf)
});
})
}
塔吊模型结构:
项目中加载了两个模型,一个是后面的山模型,另一个就是塔吊模型,加载模型以后需要对模型进行处理,比如绘制线稿、添加阴影,提取立柱和横梁模型等,都在handleModules.ts中进行的,
模型名称示意:
// 模型名称示意:
// cabine:小木屋;座舱;机舱
// plateforme:平台;月台
// plateforme-haut:上平台
// cables-haut:上电缆
// poulie:滑轮;滑车
// pied:脚;足;支柱
// beton-haut:上混凝土
// grue-barriere-support:起重机障碍物支架
// barrieres:障碍物;栅栏
// attaches-haut:上附件
// corps-haut-1:上体 1
// corps-haut-2:上体 2
// corps-haut-3:上体 3
// haut-jaune:黄色上部
// haut-gris:灰色上部
// corps-top:顶部主体
// accroche:挂钩;抓住
// RootNode: 旋转臂组
通过getObjectByName获取立柱对应的模型,删除原有模型(removeFromParent)并赋值给corpsCopy作为新的模型以便后续动态渲染立柱时候使用,横梁同理。
const corps002 = piedClone.getObjectByName('corps002');
if (corps002) {
corps002.removeFromParent()
corpsCopy = corps002.clone()
corpsCopy.traverse((mesh: any) => {
setLayers(mesh, 'crane')
mesh.castShadow = true
})
}
复制模型后通过traverse方法进行遍历,找到每一个模型,设置每一个模型的castShadow属性让模型产生阴影,setLayers设置模型的layers,图例根据不同模型的不同layers进行展示和隐藏
设置layers
export const setLayers = (mesh: Object3D, type: string) => {
const meshLayers = LayersMap.get(type);
if (meshLayers !== undefined) {
mesh.layers.set(meshLayers)
}
}
layersMap是模型类型和layers值的映射,在layerMap.ts文件内,如果想添加其他图例 也可以,只要camera设置的enable和模型的layers对应上,就可以控制显示隐藏
export const LayersMap = new Map([
['line', 2],
['mountain', 1],
['crane', 3],
['line-tag', 5],
['device-tag', 5],
])
以上展示了立柱的获取方法,通过getObjectByname的api获取相对应的模型,并赋值给一个公共变量
// 立柱标准节模型
export let corpsCopy: Object3D
// 立柱组
export let piedClone: Object3D
// 旋转上臂组
export let RootNode: Object3D
// 横柱标准节模型
export let corpsHautCopy: Object3D
于是我们得到了立柱和横梁的标准节模型
黄色框内两部分就是横梁和立柱的标准节,通过右侧的表单可以复制对应的数量并堆叠在一起,在dynamicConfig.ts文件中进行组装,我们拿横梁作为例子:
动态计算横梁的实际尺寸和模型尺寸
export const rowConfig = (rowCount: number) => {
const size = corpsHautCopy.userData.size
modalLength = size.z * rowCount
// 横梁标准件3米 * 数量,为总长
rowLength = rowCount * 5
// 横梁移动范围 为总长度
rowEffectiveRange.push(rowLength - 1)
for (let i = 0; i < rowCount; i++) {
const newHau = corpsHautCopy.clone();
newHau.position.z = -size.z * i
RootNode.add(newHau)
newHau.traverse((mesh: any) => {
const line = getLine(mesh, 10, undefined, 0.5)
mesh.parent.add(line)
setLayers(line, 'line')
})
}
}
从这个方法中得到:
模型总长度modalLength:标准件数量 rowCount * 标准件模型宽度 corpsHautCopy.userData.size.z(加载模型时通过getBox3Info获取corpsHautCopy.userData.size = size)
实际总长度allLength:标准件数量 * 标准件实际宽度 3米(模拟的)
标准件位置 -size.z * i 标准件模型宽度 * 索引(第几个标准件)
这样我们就计算出了标准件组成的实际宽度和模型宽度,用来计算比例,在代码中对模型绘制了线稿和设置线稿的layers
横梁和立柱都设置最大值9时的效果图:
模拟横梁上小车的位置
移动逻辑 distance.ts文件中包含修改方向盘对应位置,绘制距离标注等功能DistanceTags类用作绘制距离标注和动态修改距离标注的功能
模拟数据字段:distance(推送传入)
横梁实际总长度:allLength(前文获取到的)
横梁模型总长度:modalLength(前文获取到的)
计算比例 sl:distance/allLength
计算小车位置:modalLength * sl
changeModaPosition(distance: number) {
this.distance = distance
const sl = this.distance / this.allLength;
this.proportion = sl
const position = this.modalLength * sl
this.name2d.element.innerText = `${distance} 米`
return position
}
绘制标注信息
还是以横梁举例:横梁的标注信息含有白色的区域线,还有标签文字,首先要确定区域线的几个定点,首先确认start点位和end点位,再根据固定的偏移量设定其他两点,一共四个点位组成一个区域线。这里讲的是初始化时的标注信息。
// 获取横梁小车的世界坐标
const { worldPosition } = getBox3Info(poulie_poulie_0)
const start = new Vector3(0, worldPosition.y, 0)
const point1 = new Vector3(0, worldPosition.y + offset, 0)
const end = start.clone().setZ(worldPosition.z)
const point2 = end.clone().setY(start.y + offset)
起点是从0,0,0的位置将高度设置为worldPosition.y,这个是横梁小车的y轴世界坐标,从起点出发,到point1点位,只是将y轴改变了,让它变得更高,offset是自定义的常亮,展示图中的效果的offset是80,当然也可以自定义,根据自己喜好来,end坐标和start坐标唯一的区别就是z轴位置变了,变得更远,也是根据横梁小车的z轴世界坐标获取,而point2是基于end点位将y轴加一个offset的距离。这样四个点就形成了一个区域
动态改变标注
动态改变横梁标注信息在changeRowDistance方法中需要重新计算四个点位,改变的只有end和point2,因为在横梁小车移动时,只有结束的位置改变了,记得上面提到的修改小车position的方法了么,通过获取的position,来计算end和point2的点位信息
const end = start.clone().setZ(-position)
const point2 = end.clone().setY(start.y + offset)
创建和修改标注信息
通过前面确定的点位信息构建成一个Float32Array再使用BufferGeometry缓冲几何形状将这几个点位信息设置为顶点信息
getGeometry() {
const vertices = new Float32Array([
...this.poulieStart.toArray(),
...this.point1.toArray(),
...this.point2.toArray(),
...this.poulieEnd.toArray()
]);
const geometry = new BufferGeometry();
// itemSize = 3 因为每个顶点都是一个三元组。
geometry.setAttribute('position', new BufferAttribute(vertices, 3));
return geometry
}
这样我们就得到了一个缓冲几何形状,再使用line2.ts中提供的方法geometryAttribute2Array将定点信息转成line2线段,
// 将BufferGeometry的顶点信息转成数组
export const geometryAttribute2Array = (geometry: BufferGeometry) => {
let linePoints = []
if (geometry.isBufferGeometry) {
const position = geometry.getAttribute('position')
const { count } = position
// 循环几何体的顶点信息并加入到linePoints中。
for (let i = 0; i < count; i++) {
const v3 = new Vector3().fromBufferAttribute(position, i);
linePoints.push(v3.x, v3.y, v3.z)
}
}
const line2Geometry = new LineGeometry();
line2Geometry.setPositions(linePoints);
let line2 = new Line2(line2Geometry, radarLine2MatLine);
return line2
}
想要绘制线段只要传入geometry就可以了,有了顶点信息也绘制出区域线了,那么接下来就是文字标注了,文字标注使用到的就是前面提的css2dObject了,其实就是一个dom元素,确定一下position就可以了,而这个position就是point1和point2这两个点位中间的位置:
getCss2dLabelPos() {
const center = this.point2.clone().sub(this.point1).divideScalar(2)
return center
}
用到的都是vector3向量的api:sub相减和divideScalar除以标量
创建css2dObject时需要先创建一个div再将想要放的内容append给div或者直接修改div的innerHTML,当然我这里使用的是原生html,如果用react或者vue这样的mvvm框架的话,需要将框架组件转成html元素后再去创建css2dObject对象。
export const css2dContent = (info: DistanceLabelType) => {
// 创建一个div元素
const moonMassDiv = document.createElement('div');
moonMassDiv.classList.add(info.class);
moonMassDiv.innerHTML = `${info.text}`
if (info?.clickBack) {
moonMassDiv.addEventListener('click', info?.clickBack)
}
const label = new CSS2DObject(moonMassDiv);
return label
}
同理驾驶舱的那个标注也是这样写的。
模拟数据推送
键盘监听回调在craneOperate.ts文件中,操作方法有三种,
上下移动平台 changeColumnDistance
左右移动横梁小车 changeRowDistance
旋转塔吊方法
changeRowDistance修改横梁小车的方法前面讲过了,上下移动平台方法和左右移动的方法类似,只不过是轴向变了,左右移动改变的是模型position的Z轴,而上下移动改变的是模型的Y轴;
旋转塔吊相对其他比较简单。需要旋转的模型组在整个塔吊的模型里名称是RootNode,所以我们还用getObjectByName获取到对应的模型,在操作的时候改变rotation.y即可。
RootNode.rotation.y += rotateStep
有一点需要注意,这个旋转角度是要和右下角2维图例的旋转相同,但是呢,rotation是代表弧度,是一个euler欧拉角,而css的transform的rotate是角度deg,所以这时候就需要转变一下了,利用弧度转角度的方式
if (needle) {
// rotate的弧度换算成transform用的角度
(needle as any).style.transform = `rotate(${-rotate * 180 / Math.PI}deg)`
}
这样3d模型的旋转和2d图例的旋转就对应上了
图例显示隐藏
图例显示隐藏只是一个例子,主要满足显示的东西太多而导致画面变得凌乱,还那防碰撞功能举例,多个塔吊模型的时候,要专注看某两个模型之间的碰撞关系,就需要隐藏掉其他的塔吊模型,而又不能直接删除,再看的时候再加载一次,所以要用到layers,前面处理模型的时候已经将layers设置进去了,所以监听图例的点击事件做出对应的显示或者隐藏动作即可,功能在文件layers.ts中
...
if (newState) {
camera.layers.enable(layers)
} else {
camera.layers.toggle(layers)
}
...
抛去dom的操作只剩两个api,就是enable启用某个图层接受一个图层序列,在前文定义的layersMap映射对应的layers值,toggle隐藏该图层,具体api看camera和Object3d的介绍,用处很大,这是官网的介绍:
物体的层级关系。 物体只有和一个正在使用的Camera至少在同一个层时才可见。当使用Raycaster进行射线检测的时候此项属性可以用于过滤不参与检测的物体.
结语
项目不是很大,但是包含的内容都是threejs基础的,不要把threejs想的多高级多难,只是一个库,不学不会对工资有影响,也不影响做leader和升迁,
如果感兴趣万一不小心学会了,也不会有什么好处,顶多职业生涯多了一个技能,仅此而已
就像当年学会jquery或者学会了echart一样,该淘汰还是淘汰,该用不到还是用不到,
如果真的是对图形学感兴趣,可以学原生webgl或者opengl,那完全是另一个方向了...
源码下载地址
相关源码的下载链接地址点击链接进行跳转