threejs开发可视化数字城市效果

灵感图

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

本文比较长,每个阶段代码都提供tag,可以分步骤查看,请耐心品尝

用到的技术栈 vite + typescript + threejs

白模

下载白模

模型下载网站 [上海模型](City- Shanghai-Sandboxie - Download Free 3D model by Michael Zhang (@beyond.zht) [3eab443] (sketchfab.com))

搜索关键词:city

压缩包包含的内容

模型加载

模型下载的是gltf格式,所以要用到threejs 提供的 # GLTFLoader,下面是具体代码

typescript 复制代码
export function loadGltf(url: string) {
    return new Promise<Object>((resolve, reject) => {
        gltfLoader.load(url, function (gltf) {
            console.log('gltf',gltf)
            resolve(gltf)
        });
    })
}
处理模型

模型上有一些咱们用不到的模型,进行删除,还有一些用的到的模型,但是名称不友好,所以进行整理

typescript 复制代码
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是场景的构建函数,具体可以查看 gitee中的文件,这里就不赘述了,主要创建了场景,镜头,控制器,灯光等基础信息,并且监听控制器变化时修改灯光位置

在使用第三方模型的时候,总有一些不尽人意的地方,比如模型加载后,模型中心并不在3d世界的中心位置,所以就需要调整一下模型整体的位置,toSceneCenter 方法是自定义的一个让模型居中的方法,通过# Box3获取到模型的包围盒,获取到模型的中心点坐标信息,再取反,就会得到模型中心点在3d世界的位置信息

typescript 复制代码
// 获取包围盒
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))
}
阶段代码

以上代码地址 城市加载白模 v2.0.1

飞线

收集飞线的点

没有3d设计师的支持,所有的数据都来自于模型,所以利用现有条件,收集飞线经过的点位,原理就是使用到的鼠标射线,点击模型上的某个位置并记录下来,提供给后期使用

众所周知,click的调用过程是忽略mousedown的,mouseup时候就会调用,如果单纯的想要改变视角,鼠标抬起时候也会调用click事件,所以要加一个鼠标是否移动的判断,利用控制器监听start和end时的镜头位置变化来区分鼠标是否移动

控制器部分代码:

typescript 复制代码
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回调将不会调用

射线部分代码:

typescript 复制代码
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)
        }
    });
}

射线的回调:

typescript 复制代码
let arr = []
T.ray(group.children, (meshList) => {
    console.log('meshList', meshList);
    arr.push(...meshList[0].point.toArray())
    console.log(JSON.stringify(arr));
})

收集后的顶点信息:

这部分的工作和之前写 # threejs 打造 world.ipanda.com 同款3D首页时候收集熊猫基地的点位是一样的,只不过判断鼠标是否移动的部分不一样而已。

细化顶点

有了飞线具体经过的点位时候,要将这些点位细化,这时就要讲飞线的大致原理了,两点确定一条线段,获取线段上的100个点,每条飞线占用20个点位,每个点位创建一个着色器,用于绘制飞线的组成部分,当更新时候,飞线的首个点向下一个点前进,一次往后20个点都往前前进一次,循环往复一直到飞线的最后一个组成部分到达线段的最后一个点,飞线占用的点位数量决定飞线的长度,将线段分为多少个顶点,决定飞线的疏密程度,像图中这样的疏密度,就是单个线段的点位分少了,这个可以优化的,vector3.distanceTo(vector3)即可判断两个线段的长度,通过不同的长度,决定细化线段的点,当然,线段的顶点信息越多,对gpu的消耗越大

typescript 复制代码
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参数

typescript 复制代码
interface SetFly {
    index: number, // 截取起点
    num: number, // 截取长度 // 要小于length
    points: Vector3[],
    spaced: number // 要将曲线划分为的分段数。默认是 5
    starColor: Color,
    endColor: Color,
    size: number
}

endColor和starColor目前不好用,做不出渐变,不知道是不是长度不够,暂时先放放

flyLine

创建flyLine做成了一个类,开箱即用,也可以加入自己的想法,调整内容,

创建flyLine之后要在render中调用

typescript 复制代码
 render() {
    this.controls.update()
    this.renderer.render(this.scene, this.camera);
    this._Fly && this._Fly.upDate()
}

new Fly() 方法详见飞线fly.ts

可配置参数有尺寸,透明度,颜色等

typescript 复制代码
 var color1 = params.starColor; //轨迹线颜色 青色
var color2 = params.endColor; //黄色
var color = color1.lerp(color2, i / newPoints2.length)
colorArr.push(color.r, color.g, color.b);

这里是引用渐变色的位置,需要再调整一下

阶段代码

以上代码地址 城市飞线 v2.0.2

线稿

将模型绘制出线稿,并添加到原有模型上,这里用到LineBasicMaterial基础线条材质,和MeshLambertMaterial基础网格材质,调节颜色和不透明度。

材质代码:

typescript 复制代码
// 建筑材质
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
        }
    )
}

以下代码是之前对模型改造时写的对模型重命名的方法,现在我们来改造一下

typescript 复制代码
// 重命名模型
    // 环球金融中心
    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);通过边缘几何体的信息创建# 线段(LineSegments);并将创建出来的线段添加到原有模型中,因为我们的线段不需要单独处理,所以这里写的方法会比之前在# threejs渲染高级感可视化涡轮模型一文中写的简化的很多,如果需要单独对线段处理的同学,可以采用这篇文章里的 changeModelMaterial 方法

typescript 复制代码
/**
 * 
 * @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信息,

html 复制代码
    <div id="css2dRender"></div>
加载css2drender

createScene 文件

typescript 复制代码
 +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节点
typescript 复制代码
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属性改变一下,忽略这个图层的任何事件。

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

由于pointer-events属性是可以继承的,2d图层内所有的元素都不响应事件,所以要将咱们创建的建筑tag的样式改一下

css 复制代码
.build_tag {
  /* 一定要加这个属性,不然2D内容点击没效果 */
  pointer-events: all;
}

第一次写这方面的代码的时候,也是头疼了好久,慢慢摸索才摸索出来的。

typescript 复制代码
// 创建建筑标记
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基础内容,这里就不赘述了

typescript 复制代码
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的代码

typescript 复制代码
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(() => {
            // 动画结束的回调,可以展示建筑信息或其他操作
        })
}
阶段代码

以上代码地址 建筑镜头动画 v2.0.4

场景背景渲染

scene的场景不仅支持颜色和texture纹理,还支持canvas,上面的黑色背景太单调了,所以利用canvas绘制一个圆渐变填充到scene.background

typescript 复制代码
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渲染高级感可视化涡轮模型 # 写一个高德地图巡航功能的小DEMO

# three.js 打造游戏小场景(拾取武器、领取任务、刷怪)

# threejs 打造 world.ipanda.com 同款3D首页

# three.js------物理引擎

# three.js------镜头跟踪

# threejs 笔记 03 ------ 轨道控制器

# Javascript基础之有趣的文字效果

# Javascript基础之写一个好玩的点击效果

# Javascript基础之鼠标拖拉拽

相关推荐
并不会33 分钟前
常见 CSS 选择器用法
前端·css·学习·html·前端开发·css选择器
衣乌安、36 分钟前
【CSS】居中样式
前端·css·css3
兔老大的胡萝卜37 分钟前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
低代码布道师39 分钟前
CSS的三个重点
前端·css
耶啵奶膘2 小时前
uniapp-是否删除
linux·前端·uni-app
王哈哈^_^4 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie4 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic5 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿5 小时前
webWorker基本用法
前端·javascript·vue.js
cy玩具5 小时前
点击评论详情,跳到评论页面,携带对象参数写法:
前端