threejs 開發可視化數字城市效果

現在隨着城市的發展,越來越多的智慧攝像頭,都被互聯網公司布到城市的各個角落,舉一個例子,一個大樓上上下下都被佈置了智能攝像頭,用於監控火勢,人員進出,工裝工牌佩戴等監控,這時候爲了美化項目,大公司都會將城市的區域作爲對象,進行 3d 可視化交互,接下來的內容,就是基於以上元素,開發的一款城市數據可視化的 demo,包含樓宇特效,飛線,特定視角,動畫等交互,希望可以給大家帶來一 neinei 的幫助,話不多說,開整

用到的技術棧 vite + typescript + threejs

白模

下載白模

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

搜索關鍵詞:city

壓縮包包含的內容

模型加載

模型下載的是 gltf 格式,所以要用到 threejs 提供的 # GLTFLoader,下面是具體代碼

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 是場景的構建函數,具體可以查看 gitee 中的文件,這裏就不贅述了,主要創建了場景,鏡頭,控制器,燈光等基礎信息,並且監聽控制器變化時修改燈光位置

在使用第三方模型的時候,總有一些不盡人意的地方,比如模型加載後,模型中心並不在 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))
}
階段代碼

以上代碼地址 城市加載白模 v2.0.1

飛線

收集飛線的點

沒有 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));
})

收集後的頂點信息:

這部分的工作和之前寫 # threejs 打造 world.ipanda.com 同款 3D 首頁時候收集熊貓基地的點位是一樣的,只不過判斷鼠標是否移動的部分不一樣而已。

細化頂點

有了飛線具體經過的點位時候,要將這些點位細化,這時就要講飛線的大致原理了,兩點確定一條線段,獲取線段上的 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()
}

new Fly() 方法詳見飛線 fly.ts

可配置參數有尺寸,透明度,顏色等

 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基礎網格材質,調節顏色和不透明度。

材質代碼:

// 建築材質
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);通過邊緣幾何體的信息創建 # 線段(LineSegments);並將創建出來的線段添加到原有模型中,因爲我們的線段不需要單獨處理,所以這裏寫的方法會比之前在# threejs 渲染高級感可視化渦輪模型 一文中寫的簡化的很多,如果需要單獨對線段處理的同學,可以採用這篇文章裏的 changeModelMaterial 方法

/**
 * 
 * @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>
加載 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 節點

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(() ={
            // 動畫結束的回調,可以展示建築信息或其他操作
        })
}

階段代碼

以上代碼地址 建築鏡頭動畫 v2.0.4

場景背景渲染

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

其他風格

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/1o6HrXTxQTprP8LKMLEcLA