threejs!可视化数字城市效果的实现

灵感图

现在随着城市的发展,越来越多的智慧摄像头,都被互联网公司布到城市的各个角落,举一个例子,一个大楼上上下下都被布置了智能摄像头,用于监控火势,人员进出,工装工牌佩戴等监控,这时候为了美化项目,大公司都会将城市的区域作为对象,进行3d可视化交互,接下来的内容,就是基于以上元素,开发的一款城市数据可视化的demo,包含楼宇特效,飞线,特定视角,动画等交互,

用到的技术栈 vite + typescript + threejs

白模
下载白模

搜索关键词:city

压缩包包含的内容

threejs!可视化数字城市效果的实现_第1张图片

模型加载

下面是具体代码

export function loadGltf(url: string) {
    return new Promise((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>[]) => 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做成了一个类,开箱即用,也可以加入自己的想法,调整内容,

创建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

色彩

这里可以根据一个颜色,调出互补色、相似色、单色等色彩信息

threejs!可视化数字城市效果的实现_第2张图片

取色

这个工具也可以根据一张图片,提取出主题色,包含主色、辅助色等信息

threejs!可视化数字城市效果的实现_第3张图片

阶段代码
预设镜头位置

threejs!可视化数字城市效果的实现_第4张图片

预埋点位

预埋的点位坐标信息获取和飞线点位获取一样的方法,标记采用的是CSS2DRenderer,将创建的element节点渲染到3d世界,3drender和2drender不在同一个图层内,所以需要新建一个dom节点,专门存放css2d的dom信息,

    
加载css2drender

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);
     ...
 }

 

根据数据创建dom节点

threejs!可视化数字城市效果的实现_第5张图片

export interface CameraPosInfo {
    pos: number[], // 预设摄像机位置信息
    target: number[], // 控制器目标位置
    name: string, // 预埋标记点或其他信息
    tagPos?: number[], // 预埋标记点的位置信息
}

接下来就是要根据信息创建节点,遍历这些信息,并创建节点,这里有一个点需要提一下,2d图层和3d图层的关系

threejs!可视化数字城市效果的实现_第6张图片

从图中可以看出,2d图层始终保持在3d图层的上层,然而我们在创建控制器的时候,第二个参数使用的是3d的图层, this.controls = new OrbitControls(this.camera, this.renderer.domElement),因为这一层被覆盖了,所以控制器失效了。

有两种解决方案,第一种是 new OrbitControls时,将第二个参数改为this.renderCss2D.domElement,还有一种方式,也就是本文采用的方式,将2d图层的css属性改变一下,忽略这个图层的任何事件。

#css2dRender {
  /* 一定要加这个属性,不然2D内容点击没效果 */
  pointer-events: none;
}

threejs!可视化数字城市效果的实现_第7张图片

由于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)
...
}

 

其他风格

threejs!可视化数字城市效果的实现_第8张图片

你可能感兴趣的:(前端,数据可视化)