赞
踩
现在随着城市的发展,越来越多的智慧摄像头,都被互联网公司布到城市的各个角落,举一个例子,一个大楼上上下下都被布置了智能摄像头,用于监控火势,人员进出,工装工牌佩戴等监控,这时候为了美化项目,大公司都会将城市的区域作为对象,进行3d可视化交互,接下来的内容,就是基于以上元素,开发的一款城市数据可视化的demo,包含楼宇特效,飞线,特定视角,动画等交互,
用到的技术栈 vite + typescript + threejs
搜索关键词:city
压缩包包含的内容
下面是具体代码
- export function loadGltf(url: string) {
- return new Promise<Object>((resolve, reject) => {
- gltfLoader.load(url, function (gltf) {
- console.log('gltf',gltf)
- resolve(gltf)
- });
- })
- }
模型上有一些咱们用不到的模型,进行删除,还有一些用的到的模型,但是名称不友好,所以进行整理
- loadGltf('./models/scene.gltf').then((gltf: any) => {
- const group = gltf.scene
- const scale = 10
- group.scale.set(scale, scale, scale)
-
- // 删除多余模型
- const mesh1 = group.getObjectByName('Text_test-base_0')
- if (mesh1 && mesh1.parent) mesh1.parent.remove(mesh1)
-
- const mesh2 = group.getObjectByName('Text_text_0')
- if (mesh2 && mesh2.parent) mesh2.parent.remove(mesh2)
-
- // 重命名模型
- // 环球金融中心
- const hqjrzx = group.getObjectByName('02-huanqiujinrongzhongxin_huanqiujinrongzhongxin_0')
- if (hqjrzx) hqjrzx.name = 'hqjrzx'
- // 上海中心
- const shzx = group.getObjectByName('01-shanghaizhongxindasha_shanghaizhongxindasha_0')
- if (shzx) shzx.name = 'shzx'
- // 金茂大厦
- const jmds = group.getObjectByName('03-jinmaodasha_jjinmaodasha_0')
- if (jmds) jmds.name = 'jmds'
- // 东方明珠塔
- const dfmzt = group.getObjectByName('04-dongfangmingzhu_dongfangmingzhu_0')
- if (dfmzt) dfmzt.name = 'dfmzt'
-
- T.scene.add(group)
- T.toSceneCenter(group)
-
- T.ray(group.children, (meshList) => {
- console.log('meshList', meshList);
- })
- T.animate()
- })
T是场景的构建函数,主要创建了场景,镜头,控制器,灯光等基础信息,并且监听控制器变化时修改灯光位置
在使用第三方模型的时候,总有一些不尽人意的地方,比如模型加载后,模型中心并不在3d世界的中心位置,所以就需要调整一下模型整体的位置,toSceneCenter
方法是自定义的一个让模型居中的方法,通过BOX3获取到模型的包围盒,获取到模型的中心点坐标信息,再取反,就会得到模型中心点在3d世界的位置信息
- // 获取包围盒
- getBoxInfo(mesh) {
- const box3 = new THREE.Box3()
- box3.expandByObject(mesh)
- const size = new THREE.Vector3()
- const center = new THREE.Vector3()
- // 获取包围盒的中心点和尺寸
- box3.getCenter(center)
- box3.getSize(size)
- return {
- size, center
- }
- }
- toSceneCenter(mesh) {
- const { center, size } = this.getBoxInfo(mesh)
- // 将Y轴置为0
- mesh.position.copy(center.negate().setY(0))
- }
没有3d设计师的支持,所有的数据都来自于模型,所以利用现有条件,收集飞线经过的点位,原理就是使用到的鼠标射线,点击模型上的某个位置并记录下来,提供给后期使用
众所周知,click的调用过程是忽略mousedown的,mouseup时候就会调用,如果单纯的想要改变视角,鼠标抬起时候也会调用click事件,所以要加一个鼠标是否移动的判断,利用控制器监听start和end时的镜头位置变化来区分鼠标是否移动
控制器部分代码:
- this.controls.addEventListener('start', () => {
- this.controlsStartPos.copy(this.camera.position)
- })
-
- this.controls.addEventListener('end', () => {
- this.controlsMoveFlag = this.controlsStartPos.distanceToSquared(this.camera.position) === 0
- })
控制器开始变化的时候记录camera位置,跟结束时的camera的位置相减,如果为0,则表示鼠标没晃动,单纯的点击,如果不为0,说明镜头位置变化了,这时,鼠标的click回调将不会调用
射线部分代码:
- ray(children: THREE.Object3D[], callback: (mesh: THREE.Intersection<THREE.Object3D<THREE.Event>>[]) => void) {
- let mouse = new THREE.Vector2(); //鼠标位置
- var raycaster = new THREE.Raycaster();
- window.addEventListener("click", (event) => {
- 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(children);
- if (this.controlsMoveFlag) {
- callback && callback(rallyist)
- }
- });
- }
射线的回调:
- let arr = []
- T.ray(group.children, (meshList) => {
- console.log('meshList', meshList);
- arr.push(...meshList[0].point.toArray())
- console.log(JSON.stringify(arr));
- })
收集后的顶点信息:
这部分的工作只不过判断鼠标是否移动的部分不一样而已。
有了飞线具体经过的点位时候,要将这些点位细化,这时就要讲飞线的大致原理了,两点确定一条线段,获取线段上的100个点,每条飞线占用20个点位,每个点位创建一个着色器,用于绘制飞线的组成部分,当更新时候,飞线的首个点向下一个点前进,一次往后20个点都往前前进一次,循环往复一直到飞线的最后一个组成部分到达线段的最后一个点,飞线占用的点位数量决定飞线的长度,将线段分为多少个顶点,决定飞线的疏密程度,像图中这样的疏密度,就是单个线段的点位分少了,这个可以优化的,vector3.distanceTo(vector3)
即可判断两个线段的长度,通过不同的长度,决定细化线段的点,当然,线段的顶点信息越多,对gpu的消耗越大
- flyLineData.forEach((data: number[]) => {
- const points: THREE.Vector2[] = []
- for (let i = 0; i < data.length / 3; i++) {
- const x = data[i * 3]
- const z = data[i * 3 + 2]
- const point = new THREE.Vector2(x, z)
- points.push(point)
- }
- const curve = new THREE.SplineCurve(points);
- // 此处决定飞线每个点的疏密程度,数值越大,对gpu的压力越大
- const curvePoints = curve.getPoints(100);
- const flyPoints = curvePoints.map((curveP: THREE.Vector2) => new THREE.Vector3(curveP.x, 0, curveP.y))
-
- // const l = points.length - 1
-
- const flyGroup = T._Fly.setFly({
- index: Math.random() > 0.5 ? 50 : 20,
- num: 20,
- points: flyPoints,
- spaced: 50, // 要将曲线划分为的分段数。默认是 5
- starColor: new THREE.Color(Math.random() * 0xffffff),
- endColor: new THREE.Color(Math.random() * 0xffffff),
- size: 0.5
- })
- flyLineGroup.add(flyGroup)
- })
setFly参数
- interface SetFly {
- index: number, // 截取起点
- num: number, // 截取长度 // 要小于length
- points: Vector3[],
- spaced: number // 要将曲线划分为的分段数。默认是 5
- starColor: Color,
- endColor: Color,
- size: number
- }
endColor和starColor目前不好用,做不出渐变,不知道是不是长度不够,暂时先放放
创建flyLine做成了一个类,开箱即用,也可以加入自己的想法,调整内容,
创建flyLine之后要在render中调用
- render() {
- this.controls.update()
- this.renderer.render(this.scene, this.camera);
- this._Fly && this._Fly.upDate()
- }
-
可配置参数有尺寸,透明度,颜色等
- var color1 = params.starColor; //轨迹线颜色 青色
- var color2 = params.endColor; //黄色
- var color = color1.lerp(color2, i / newPoints2.length)
- colorArr.push(color.r, color.g, color.b);
这里是引用渐变色的位置,需要再调整一下
将模型绘制出线稿,并添加到原有模型上,这里用到LineBasicMaterial
基础线条材质,和MeshLambertMaterial
基础网格材质,调节颜色和不透明度。
材质代码:
- // 建筑材质
- export const otherBuildingMaterial = (color: THREE.Color, opacity = 1) => {
- return new THREE.MeshLambertMaterial({
- color,
- transparent: true,
- opacity
- });
- }
- // 建筑线条材质
- export const otherBuildingLineMaterial = (color: THREE.Color, opacity = 1) => {
- return new THREE.LineBasicMaterial(
- {
- color,
- depthTest: true,
- transparent: true,
- opacity
- }
- )
- }
-
以下代码是之前对模型改造时写的对模型重命名的方法,现在我们来改造一下
- // 重命名模型
- // 环球金融中心
- const hqjrzx = group.getObjectByName('02-huanqiujinrongzhongxin_huanqiujinrongzhongxin_0')
- if (hqjrzx) {
- hqjrzx.name = 'hqjrzx'
- changeModelMaterial(hqjrzx, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
- }
-
- // 上海中心
- const shzx = group.getObjectByName('01-shanghaizhongxindasha_shanghaizhongxindasha_0')
- if (shzx) {
- shzx.name = 'shzx'
- changeModelMaterial(shzx, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
- }
- // 金茂大厦
- const jmds = group.getObjectByName('03-jinmaodasha_jjinmaodasha_0')
- if (jmds) {
- jmds.name = 'jmds'
- changeModelMaterial(jmds, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
- }
- // 东方明珠塔
- const dfmzt = group.getObjectByName('04-dongfangmingzhu_dongfangmingzhu_0')
- if (dfmzt) {
- dfmzt.name = 'dfmzt'
- changeModelMaterial(dfmzt, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
- }
-
- T.scene.add(group)
- T.toSceneCenter(group)
-
-
- group.traverse((mesh: any) => {
- mesh as THREE.Mesh
- if (mesh.isMesh && (mesh.name.indexOf('Shanghai') !== -1 || mesh.name.indexOf('Object') !== -1)) {
- if (mesh.name.indexOf('Floor') !== -1) {
- mesh.material = floorMaterial
- } else if (mesh.name.indexOf('River') !== -1) {
- } else {
- changeModelMaterial(mesh, otherBuildingMaterial(otherBuildColor,0.8), otherBuildingLineMaterial(otherBuildLineColor,0.4),buildLineDeg)
- }
- }
- })
-
changeModelMaterial
这个方法就是创建模型相对应的线条的方法,获取到模型的geometry
,这里存着模型所有的顶点信息,索引和法向量,以此创建一个# 边缘几何体(EdgesGeometry)边缘几何体(EdgesGeometry)# 边缘几何体(EdgesGeometry);通过边缘几何体的信息创建 # 线段(LineSegments)线段(LineSegments) # 线段(LineSegments);并将创建出来的线段添加到原有模型中,因为我们的线段不需要单独处理,
- /**
- *
- * @param object 模型
- * @param lineGroup 线组
- * @param meshMaterial 模型材质
- * @param lineMaterial 线材质
- */
- export const changeModelMaterial = (mesh: THREE.Mesh, meshMaterial: THREE.MeshBasicMaterial, lineMaterial: THREE.LineBasicMaterial, deg = 1): any => {
- if (mesh.isMesh) {
- if (meshMaterial) mesh.material = meshMaterial
- // 以模型顶点信息创建线条
- const line = getLine(mesh, deg, lineMaterial)
- const name = mesh.name + '_line'
-
- line.name = name
- mesh.add(line)
- }
- }
- // 通过模型创建线条
- export const getLine = (object: THREE.Mesh, thresholdAngle = 1, lineMaterial: THREE.LineBasicMaterial): THREE.LineSegments => {
- // 创建线条,参数为 几何体模型,相邻面的法线之间的角度,
- var edges = new THREE.EdgesGeometry(object.geometry, thresholdAngle);
- var line = new THREE.LineSegments(edges);
- if (lineMaterial) line.material = lineMaterial
- return line;
- }
对于我这种野生前端开发,没有UI和UE的支持,只能在网上找案例,那么就需要图片中的颜色,这里不得不提到一个工具色輪、調色盤產生器 | Adobe Color
这里可以根据一个颜色,调出互补色、相似色、单色等色彩信息
这个工具也可以根据一张图片,提取出主题色,包含主色、辅助色等信息
预埋的点位坐标信息获取和飞线点位获取一样的方法,标记采用的是CSS2DRenderer,将创建的element节点渲染到3d世界,3drender和2drender不在同一个图层内,所以需要新建一个dom节点,专门存放css2d的dom信息,
<div id="css2dRender"></div>
createScene 文件
- +renderCss2D: CSS2DRenderer
-
- createRenderer(){
- ...
- this.renderCss2D = new CSS2DRenderer({ element: this.css2dDom });
- this.renderCss2D.setSize(this.width, this.height);
- ...
- }
-
- render(){
- ...
- this.renderCss2D.render(this.scene, this.camera);
- ...
- }
- export interface CameraPosInfo {
- pos: number[], // 预设摄像机位置信息
- target: number[], // 控制器目标位置
- name: string, // 预埋标记点或其他信息
- tagPos?: number[], // 预埋标记点的位置信息
- }
接下来就是要根据信息创建节点,遍历这些信息,并创建节点,这里有一个点需要提一下,2d图层和3d图层的关系
从图中可以看出,2d图层始终保持在3d图层的上层,然而我们在创建控制器的时候,第二个参数使用的是3d的图层, this.controls = new OrbitControls(this.camera, this.renderer.domElement)
,因为这一层被覆盖了,所以控制器失效了。
有两种解决方案,第一种是 new OrbitControls
时,将第二个参数改为this.renderCss2D.domElement
,还有一种方式,也就是本文采用的方式,将2d图层的css属性改变一下,忽略这个图层的任何事件。
- #css2dRender {
- /* 一定要加这个属性,不然2D内容点击没效果 */
- pointer-events: none;
- }
由于pointer-events
属性是可以继承的,2d图层内所有的元素都不响应事件,所以要将咱们创建的建筑tag的样式改一下
- .build_tag {
- /* 一定要加这个属性,不然2D内容点击没效果 */
- pointer-events: all;
- }
- // 创建建筑标记
- function createTag() {
- const buildTagGroup = new THREE.Group()
- T.scene.add(buildTagGroup)
- presetsCameraPos.forEach((cameraPos: CameraPosInfo, i: number) => {
- if (cameraPos.tagPos) {
- // 渲染2d文字
- const element = document.createElement('li');
- // 将信息存入dom节点中,如果是react或者vue写的,不用这么存,直接存data或者state
- element.setAttribute('data-cameraPosInfo', JSON.stringify(cameraPos))
- element.classList.add('build_tag')
- element.innerText = `${i + 1}`
- // 将初始化好的dom节点渲染成CSS2DObject,并在scene场景中渲染
- const tag = new CSS2DObject(element);
- const tagPos = new THREE.Vector3().fromArray(cameraPos.tagPos)
- tag.position.copy(tagPos)
- buildTagGroup.add(tag)
- }
- })
- }
这里通过事件代理,点击到相应的建筑tag,从dom节点上获取到data-cameraPosInfo
属性,然后通过tween动画处理器修改控制器的taget和镜头的position。事件代理是js基础内容,
- if (css2dDom) {
- css2dDom.addEventListener('click', function (e) {
- if (e.target) {
- if(e.target.nodeName=== 'LI') {
- console.dir(e);
- const cameraPosInfo = e.target.getAttribute('data-cameraPosInfo')
- if (cameraPosInfo) {
- const {pos,target} = JSON.parse(cameraPosInfo)
- T.controls.target.set(...target)
- T.handleCameraPos(pos)
- }
- }
- }
- });
- }
handleCameraPos
的代码
- handleCameraPos(end: number[]) {
- // 结束时候相机位置
- const endV3 = new THREE.Vector3().fromArray(end)
- // 目前相机到目标位置的距离,根据不同的位置判断运动的时间长度,从而保证速度不变
- const length = this.camera.position.distanceTo(endV3)
- // 如果位置相同,不运行动画
- if(length===0) return
- new this._TWEEN.Tween(this.camera.position)
- .to(endV3, Math.sqrt(length) * 400)
- .start()
- // .onUpdate((value) => {
- // console.log(value)
- // })
- .onComplete(() => {
- // 动画结束的回调,可以展示建筑信息或其他操作
- })
- }
scene的场景不仅支持颜色和texture纹理,还支持canvas,上面的黑色背景太单调了,所以利用canvas绘制一个圆渐变填充到scene.background
- createScene(){
- ...
- const drawingCanvas = document.createElement('canvas');
- const context = drawingCanvas.getContext('2d');
-
- if (context) {
- // 设置canvas的尺寸
- drawingCanvas.width = this.width;
- drawingCanvas.height = this.height;
-
- // 创建渐变
- const gradient = context.createRadialGradient(this.width / 2, this.height, 0, this.width/2, this.height/2, Math.max(this.width, this.height));
-
- // 为渐变添加颜色
- gradient.addColorStop(0, '#0b171f');
- gradient.addColorStop(0.6, '#000000');
-
- // 使用渐变填充矩形
- context.fillStyle = gradient;
- context.fillRect(0, 0, drawingCanvas.width, drawingCanvas.height);
- this.scene.background = new THREE.CanvasTexture(drawingCanvas)
- ...
- }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。