基於 HTML5 WebGL 構建智能數字化城市 3D 全景

前言

自 2011 年我國城鎮化率首次突破 50% 以來,《新型城鎮化發展規劃》將智慧城市列爲我國城市發展的三大目標之一,並提出到 2020 年,建成一批特色鮮明的智慧城市。截至現今,全國 95% 的副省級以上城市、76% 的地級以上城市,總計約 500 多個城市提出或在建智慧城市。

基於這樣的背景,本系統採用 Hightopo 的 HT for Web 產品來構造輕量化的 智慧城市 3D 可視化場景,通過三個角度的轉換,更清晰讓我們感知到 5G 時代下數字化智能城市的魅力

預覽地址:HT 智慧城市

整體預覽圖

第一個視角下,城市以市中心爲圓心緩緩浮現,市中心就如同整座城的大腦

第二個視角下,在樓房間穿過,細緻的感受這城市的面貌

第三個視角下,鳥瞰整座城,體會智慧城市帶來的不可思議的欣喜

是不是覺得有些神奇,我們接下來就是對項目的具體分析,手把手教你如何搭建一個自己心中的夢想城市

場景搭建

該系統中的大部分模型都是通過 3dMax 建模生成的,該建模工具可以導出 obj 與 mtl 文件,在 HT 中可以通過解析 obj 與 mtl 文件來生成 3D 場景中的所有複雜模型,(當然如果是某些簡單的模型可以直接使用 HT 來繪製,這樣會比 obj 模型更輕量化,所以大部分簡單的模型都是採用 HT for Web 產品輕量化 HTML5/WebGL 建模的方案)我們先看下項目結構,源碼都在 src 文件夾中

storage 保存的便是 3D 場景文件。 index.js 是 src 下的入口文件,創建了一個 由 main.js 中導出的 Main 類,Main 類創建了一個 3D 畫布,用於繪製我們的 3D 場景,如下

import event from '../util/NotifierManager';
import Index3d from './3d/Index3d';
import { INDEX, EVENT_SWITCH_VIEW } from '../util/constant';

export default class Main {
    constructor() {
        let g3d = this.g3d = new ht.graph.Graph3dView(),

        //將3d圖紙添加到dom對象中
        g3d.addToDOM();

        this.event = event;
        //創建一個Index3d類,作爲場景初始化
        this.index3d = new Index3d(g3d);
        //調用switch方法派發EVENT_SWITCH_VIEW事件,並傳入事件類型 INDEX
        this.switch(INDEX);
    }
    switch(key = INDEX) {
        event.fire(EVENT_SWITCH_VIEW, key);
    }
    // 
}

我們用 new ht.graph.Graph3dView() 的方式創建了一個 3D 畫布,畫布的頂層是 canvas 。並創建了一個 index3d 對象,看到後面我們就能知道其實這一步就如同我們把場景“畫”上去。在 main 對象中我們還引用了 util 下的 NotifierManager 文件,這個文件中的 event 對象爲穿插在整個項目中事件總線,使用了 HT 自帶的事件派發器,可以很方便的手動的訂閱事件和派發事件,感興趣可以進一步瞭解 HT 入門手冊 ,下面便是文件內容

class NotifierManager {
    constructor() {
        this._eventMap ={};
    }

    add(key, func, score, first = false) {
        let notify = this._eventMap[key];
        if (!notify) notify = this._eventMap[key] = new ht.Notifier();

        notify.add(func, score, first);
    }

    remove(key, func, score) {
        const notify = this._eventMap[key];
        if (!notify) return;

        notify.remove(func, score);
    }

    fire(key, e) {
        const notify = this._eventMap[key];
        if (!notify) return;

        notify.fire(e);
    }
}

const event = new NotifierManager();
export default event;

notify.fire() 和 notify.add() 分別是派發和訂閱事件,類似於設計模式中的訂閱者模式,我們很清楚的能看到,NotifierManager 類就是對 HT 原有的派發器做了一個簡單地封裝 ,並在創建 main 對象的時候,調用event.fire() 自動派發了 EVENT_SWITCH_VIEW 這一事件並且傳入了事件類型 Index 。

畫布我們有了,接下來我們就應在畫布上“畫”上我們的 3D 場景了。上面我們也說過了這一步由 new Index3d() 實現的, 那麼它是如何實現 “畫” 這一步驟的呢?

我們看看較爲重要的兩個文件 ui 文件夾下的 Index3d 文件和 View 文件,兩個文件分別導出了 Index3d 和 View 兩個類, Inde3d 類繼承於 View 類,我們先來看一下 View 類的實現

import event from "../util/NotifierManager";
import util from '../util/util';
import { EVENT_SWITCH_VIEW } from "../util/constant";

export default class View {
    constructor(view) {
        this.url = '';
        this.key = '';
        this.active = false;
        this.view = view;
        this.dm = view.dm();

        event.add(EVENT_SWITCH_VIEW, (key) => {
            this.handleSwitch(key);
        });
    }
    handleSwitch(key) {
        if (key === this.key) {
            if (!this.active) {
                this.active = true;
                this.onUp();
            }
            this.dm.clear();
            util.deserialize(this.view, this.url, this.onPostDeserialize.bind(this));
        }
        // 目前是這個場景,執行 tearDown
        else if (this.active) {
            this.onDown();
            this.active = false;
        }
    }
    /**
     * 加載這個場景前調用
     */
    onUp() {
    }
    /**
     * 離開這個場景時會調用
     */
    onDown() {
    }
    /**
     * 加載完場景處理
     */
    onPostDeserialize() {
        console.log(this)
    }
}

其它內容我們就不做過多闡述了,主要說一下我們加載場景使用的 deserialize 方法,我們打開 util 下的 util 文件找到這個方法

deserialize: (function() {
 let cacheMap = {};
 /**
  * 加載 json 並反序列化
  * 
 */
 return function(view, url, cb, notUseCache) {
 let json, cache = !notUseCache;
 if (!notUseCache) {
    json = cacheMap[url];
 }
 else {
   cache = false;
 }
 // 不使用緩存,重新加載
 view.deserialize(json || url, (json, dm, view, list) => {
   cacheMap[url] = json;
   cb && cb(json, dm, view, list, cache);
  }
})()

其中的 view 就是傳入的我們之前創建的 g3d 畫布,它上面有個 deserialize 方法,用來反序列化我們的 json 格式的場景文件。可能這個時候大家會發問了,明明之前提到場景文件的是 obj 和 mtl 文件,怎麼現在又成了 json 了。不要急,要明白這些我們得先了解一下 HT 的其它基礎知識

大家肯定對一些其它框架的設計模式有所瞭解,像早期 JAVA/Spring 的 mvc ,vue 的 mvvm 等,而 HT 的整體框架類似於 mvp 或 mvvm 模式,採用了統一的 DataModel 數據模型和 SelectionModel 選擇模型來驅動所有的 HT 視圖組件。HT 官方更願意把這個模式稱之爲 ovm 即 Object Vue Mapping。基於這樣的設計,用戶只需掌握統一的數據接口,就能熟練地使用 HT 了,並不會因爲增加了視圖組件帶來額外的學習成本,這也是爲什麼 HT 容易上手的原因。

說完這個我們在來談談上面 3D 場景文件格式的問題,HT 給我們提供了 ht.JSONSerialize 對象讓我們可以對 DataModel 進行 json 格式的序列化和反序列化,而上面的 3D 場景 json 文件就是對我們 3D 模型序列化之後的文件,調用 g3d.deserialize 方法將反序列化的對象加進 DataModel 中,那麼我們的畫布就會根據傳入的 DataModel 繪製出我們的場景了。

那麼接下來我們只要重寫 Inded3d 類上的 onPostDeserialize 方法,即繪製完場景之後的回調。就能對我們主場景進行基本操作了。

視角轉換動畫

首先,我們先完成的是三個視角轉換的動畫

我們直接寫在 util 文件當中 ,給它添加一個方法 moveEveAction。方法傳入了三個參數,首先是我們的畫布 g3d,第二個參數就是我們的視角對象,它記錄了每一步轉換的初始視角和結束視角。第三個參數是爲了銜接每一步視角轉換,讓其有一個過渡的動畫而傳入的一個函數 cover

moveEyeAction: function(g3d,moveEyeConfig,cover){
 if (!moveEyeConfig) return;
   let moveEye = function(obj,time,eas = 'liner'){
     return new Promise((res,rej) => {
                g3d.setEye(obj.initEye);
                g3d.setCenter(obj.initCenter);
                g3d.moveCamera(obj.moveEye,obj.moveCenter, {
                    duration:time,
                    easing: function(t){    
                        if(t < 0.5){
                            cover(t,'up');
                        }
                        if (eas === 'ease-in'){
                            return t * t;
                        }
                        else if (eas === 'liner'){
                            return t 
                        }
                        else {
                            return t
                        }  
                    },
                    finishFunc: ()=>{
                        cover(1,'down');
                        res(time);
                    }
                });
            })
        }
        
 moveEye(moveEyeConfig[0],moveEyeConfig[0].time,moveEyeConfig[0].eas)
   .then((res)=>{
            console.log(1)
            return moveEye(moveEyeConfig[1],moveEyeConfig[1].time,moveEyeConfig[1].eas)
   })
   .then((res)=>{
            moveEye(moveEyeConfig[2],moveEyeConfig[2].time,moveEyeConfig[2].eas)
   )}
})

我們在函數中創建了一個方法 moveEye,它創建並返回了一個 promise ,方便我們做回調,防止出現回調地獄的情況。然後我們只要提前先配置好每一步的視角,傳入函數中,函數便會依次調用 g3d 上的 moveCamera 方法,在每一步動畫結束的時候,調用 cover 函數作爲過渡。

我們再來看一下 cover 函數的實現,在 3D 場景初始化時便會調用下方的 create2dCover 方法創建 cover,其實就是在最外層蓋上了一層 div ,每一步動畫結束的時候,根據傳入的參數決定是否變暗完成過渡

 1create2dCover(){
 let div = document.createElement("div");
 div.style.position = 'absolute';
 div.style.background = 'black';
 div.style.opacity = 0;
 div.style.top = '0';
 div.style.right = '0';
 div.style.bottom = '0';
 div.style.left = '0';
 div.style.pointerEvents = 'none';
 document.body.appendChild(div);
 let dire = 'up';
 let cover = function(t,direction,num){
   if (direction === 'up' && dire === 'down'){
     div.style.opacity = 1- t * 4;
     if (t > 0.5) dire = 'up';
    }
   if (direction === 'down' && dire === 'up'){
     if (t === 1) {
       div.style.opacity = t;
       dire = 'down'; 
     }
   }
 }
 return cover;
}

我們再來看一下動畫效果

  

第一個視角下的建築浮現動畫

我們先看下 Index3d 類的實現,再加載完場景的時候,我們便會調用上面我們說過的視角轉換函數 moveEyeAction , 和我們接下來要講的城市浮現函數 upCityDemo。

onPostDeserialize(json, dm, view) {
 const g3d = this.view;
 g3d.setFar(100000);
 const nodeUpArr1 = [], nodeUpArr2 = [], nodeUpArr3 = [];
 //視角配置參數
 const moveEyeConfig = [{
   initEye:[-700,390,-974],
   initCenter:[-1596,25,-518],
   moveEye:[-2572, 390, -974],
   moveCenter:[-1596,25,-518],
   time: 9000,
   eas: 'ease-in'
   },{
   initEye:[1500,71,900],
   initCenter:[-1823,25,-636],
   moveCenter:[-1823,25,-636],
   moveEye:[-1678, 18, -558],
   time:8000
   },{
   initEye:[2491,600,-1026],
   initCenter:[0,0,0],
   moveEye:[-3105, 500, -1577],
   moveCenter:[-1034, -12, -41],
   time:8000
   }]
 //創建一個蒙板div並返回cover函數
 let cover = this.create2dCover();
 //浮現城市的屬性初始化
 dm.each(fnode => {
 //第一批樓房-市中心    
 if (fnode.getDisplayName() === "up1"){
   fnode.a('startE',fnode.getElevation());
   fnode.setElevation(-200);
   nodeUpArr1.push(fnode);
  }
 //第二批城市-市中心附近建築
 if (fnode.getDisplayName() === "up2"){
   fnode.a('startE',fnode.getElevation())
   fnode.setElevation(-100);
   nodeUpArr2.push(fnode);
 }
 //第三批城市-外圍建築
 if (fnode.getDisplayName() === "up3"){
   fnode.a('startE',fnode.getElevation())
   fnode.setElevation(-100);
   nodeUpArr3.push(fnode);
 }

 if(fnode.getDisplayName() === '飛光組'){
   fnode.eachChild(node => {
     node.s('shape3d.opacity',0);
   })
 }
54})

 //視角開始變換
 util.moveEyeAction(g3d,moveEyeConfig,cover)
 //城市浮現
 let upCityDemo = function(nodeArr,time,T = 0.6){
   return new Promise((res,rej)=>{
   ht.Default.startAnim({
     duration:time,
       action: (v,t) => {
         nodeArr.forEach((node)=>{
           if(t > T) res('已完成');
           let org = node.getElevation();
           let tar = node.a('startE');
           node.setElevation(org + (tar - org) * v)
         })
        }
     })
   })
 }
        
 upCityDemo(nodeUpArr1,11000,0.4).then((res)=>{
    // console.log(res)
   return upCityDemo(nodeUpArr2,2000,0.4)
 }).then((res)=>{
   return upCityDemo(nodeUpArr3,2000);
 }).then((res)=>{
   //城市出現,開始動畫
   //this.startAnimation(g3d,dm);
 })
84}

首先我們將城市分別分爲三批放入不同的數組中,然後類似的,創建了 upcityDemo 並返回了一個 promise,我們只需要調用並傳入每批城市節點,它們便會依次執行建築上升。還有一點要提的是這裏動畫用的是 HT 提供的動畫函數 ht.Default.startAnim 。這裏我們簡單介紹一下,HT 提供了 Frame-Based 和 Time-Based 兩種動畫方式,根據是否設置了 frames 和 interval 屬性來決定是哪種方式。 第一種方式用戶通過指定 frames 動畫幀數, 以及 interval 動畫幀間隔參數控制動畫效果。 第二種 Time-Based 用戶只需要指定 duration 的動畫週期的毫秒數即可,HT 將在指定的時間週期內完成動畫, 值得一提的是不同於 Frame-Based 方式有明確固定的幀數即 action 函數被調用的次數,Time-Based 方式的幀數或 action 函數被調用次數取決於系統環境 (類似於 setinterval 和 requestAnimate 的區別)

我們先看下動畫效果,第一步視角下的動畫轉換我們就算完成了

貫穿全部視角下的動畫

我們所有的動畫和上面一樣通過 ht.Default.startAnim 函數實現,我們只需要將不同的動畫函數放入 action 中,並通過控制它們不同的步數就能實現不一樣的速度效果。

我們共有五個動畫效果,旋轉動畫可以歸爲一類

· 建築下的水波擴散動畫

· 風車,建築底下光圈旋轉動畫

· 道路偏移動畫

· 市中心上方光線流動動畫

· 建築上面的數字飛光動畫

ht.Default.startAnim({
            frames: Infinity,
            interval: 20,
            action: () => {
                //擴散水波動畫
                waveScale(scaleList,dltScale,maxScale,minScale);
                //風車旋轉,建築底下光圈旋轉
                rotationAction(roationFC,dltRoattion);
                rotationAction(roationD,dltRoattionD);
                rotationAction(roationD2,-dltRoattionD2);
                //道路偏移
                uvFlow(roadSmall,dltRoadSmall);
                uvFlow(roadMedium,dltRoadMedium);
                uvFlow(roadBig,dltRoadBig);
                //光亮建築下的數字飛光
                numberArr.forEach((node,index)=>{
                    blockFloat(node,numFloadDis);
                })
                //市中心上方亮線的流動
                float.eachChild(node => {
                    let offset = node.s('shape3d.uv.offset') || [0, 0];
                    node.s('shape3d.uv.offset', [offset[0] + 0.05, offset[1]]);
                })  
            }
        });

我們先講前面四種較爲簡單動畫的實現,像市中心上方亮線的流動動畫邏輯簡單,我們就直接寫在了 action 函數中,每一步控制 x 方向上的貼圖偏移即可

其它動畫我們都封裝爲了對應的函數,如下

//道路偏移動畫
//定義三種道路的步進
const dltRoadSmall = 0.007, dltRoadMedium = 0.009, dltRoadBig = 0.01;
//獲取三種道路節點
let roadSmall = dm.getDataByTag('roadSmall');
let roadMedium = dm.getDataByTag('roadMedium');
let roadBig = dm.getDataByTag('roadBig');
let float = dm.getDataByTag('float');
//定義偏移動畫函數
let uvFlow = function(obj,dlt){
    let offset = obj.s('all.uv.offset') || [0, 0];
    obj.s('all.uv.offset', [offset[0] + dlt, offset[1]]);
}

//水波縮放動畫
//定義擴大範圍和每步擴大速度
const maxScale = 1.5, dltScale = 0.06;
//獲取縮放節點
let scaleList = dm.getDataByTag('scale');
//定義縮放函數
let waveScale = function(obj, dlt, max, min){
    obj.eachChild(node => {
        // 擴散半徑增加
        if (!node.a('max')) node.a('max', node.getScaleX() + max);
        if (!node.s('shape3d.opacity')) node.s('shape3d.opacity',1);
        let s = node.getScaleX() + dlt;
        let y = node.getScale3d()[1]
        let opa = node.s('shape3d.opacity') - 0.02;
        // 擴散半徑大於最大值的時候,重置爲最小值,透明度設爲1
        if (s >= node.a('max')){
            opa = 1;
            s = 0;
        } 
        // 設置x,y,z方向的縮放值
        node.s('shape3d.opacity',opa)
        node.setScale3d(s, y, s);
        });
}
//旋轉圖元
//定義三種不同旋轉圖元數組和旋轉速度
const roationFC = [], roationD = [], roationD2 = [], dltRoattionD = Math.PI / 90, dltRoattionD2 = Math.PI / 60, dltRoattion = Math.PI / 30;
//獲取所有旋轉圖元並分別放入數組中
let roationFCDatas = dm.getDataByTag('roationFC');
let roationdDatas = dm.getDataByTag('di');
roationFCDatas.eachChild(node =>{
    node.eachChild(node => {
        if (node.getDisplayName() === '風機葉片'){
            roationFC.push(node);
        }
    })  
});
roationdDatas.eachChild(node => {
    if (node.getDisplayName() === '底'){
        roationD.push(node)
    }
    if (node.getDisplayName() === '底2'){
        roationD2.push(node)
    }
});
//定義旋轉函數
let rotationAction = function(obj,dlt){
    obj.forEach(node => {     
        if (node.getDisplayName() === '風機葉片'){
            //獲得當前旋轉角度
            let rotationZ = node.getRotation3d()[2];
            //每步增加dlt
            node.setRotation3d([0,0,rotationZ + dlt]);
        }
        if (node.getDisplayName() === '底' || node.getDisplayName() === '底2'){
            //獲得當前旋轉角度
            let rotationY = node.getRotation3d()[1];
            //每步增加dlt   
            node.setRotation3d([0,rotationY + dlt,0]);
        }
    })
}

寫完之後我們再看一下動畫效果

最後就是我們的稍微繁瑣一點的數字飛光動畫了。每座城市上方都有不同的六條飛光,我們需要每次都是隨機出現兩條,並且每條的速度都是不一樣的。和之前的動畫一樣的,我們先獲取所有的飛光節點並分類好,如下

 //數字浮動
let numberArr, numFloadDis = 15, numFloatDlt = 0.07;
numberArr = new Array(28);
for (let i = 0;i < 28; i++){
    numberArr[i] = new Array(6)
}
//產生兩個隨機數,並以數組形式返回
let randerdom2 = function(){ 
    let num1 = Math.floor(Math.random() * 3);
    let num2 = Math.floor((Math.random() * 3 + 3));
    return [num1,num2];
}
//將所有的浮動數字按城市分組添加進數組
let i = 0,j=0;
dm.each(node => {
    if (node.getDisplayName() === '飛光組'){
        node.eachChild(node => {
            node.s('shape3d.opacity',0);
            node.setElevation(0);
            numberArr[i][j++] = node;
        })
        j=0;
        i++;
    }
});
//屬性初始化
let initArrAtr = function(){
    for (let i = 0; i < numberArr.length; i++){
        for (let j = 0; j < numberArr[i].length; j++){
            //每條數字的隨機數度
            numberArr[i][j].a('randomSpeed', (numFloatDlt * 100 + Math.floor(Math.random() * 5))/100);
            //控制每條數字是否停止上升
            numberArr[i][j].a('stop',false);
            //每棟樓上的已升起的飛光數量
            numberArr[i].comNum = 0;
            //每棟樓層當前的兩條飛光
            numberArr[i].one = randerdom2()[0];
            numberArr[i].two = randerdom2()[1];
        }
    }
}
initArrAtr();
//重置單樓屬性
let czArr = function(singleRoom){
        //每棟樓上的已升起的數量
        singleRoom.comNum = 0;
        //重新隨機設置每棟樓層出現的兩條飛光
        singleRoom.one = randerdom2()[0];
        singleRoom.two = randerdom2()[1];
        //設置飛光的隨機速度
        singleRoom.forEach((node, index)=>{
            node.a('stop',false);
            node.a('randomSpeed', (numFloatDlt * 100 + Math.floor(Math.random() * 5))/100);
        })
}

當初始屬性都設置完成後就該定義我們的動畫函數了

 let blockFloat = function(obj, dis){
    //獲取當前建築
    let allNumArr = obj;
    //獲取當前建築出現的兩條飛光
    let floatArr = [allNumArr[allNumArr.one],allNumArr[allNumArr.two]];
    let lth = floatArr.length;
    //遍歷並控制這兩條飛光及動畫
    for (let j = 0; j < lth; j++){
        let node = floatArr[j];
        //如果當前飛光已停則停止此條飛光下一步動畫
        if (node.a('stop')) continue;
        //獲得當前飛光初始高度如果沒有則手動設置當前爲初始高度
        let startE = node.a('startE');
        if (startE == null) node.a('startE', startE = node.getElevation());
        // 獲得當前飛光速度和透明度值
        let dlt = node.a('randomSpeed');
        let float = node.a('float') || 0;
        let opa = node.s('shape3d.opacity') || 0,
            opaDlt = 0.01;
        
        node.setElevation(startE + dis * float);
        //上升的高度到達一定值設置透明度爲1
        if (float > 8){
            node.s('shape3d.opacity',1)
            opaDlt = -0.02
        }
        //上升的高度到達最高則讓當前建築飛光到達數量加一,並停止進一步上升
        if (float > 12){
            allNumArr.comNum ++;
            node.a('stop',true);
            node.a('float', 0);
            node.setElevation(startE);
            node.s('shape3d.opacity',0);
            //當前建築飛光到達數量到達兩條,重置建築上所有飛光屬性
            if (allNumArr.comNum === 2){
                czArr(allNumArr);
            }
            continue;
        }
        float += dlt;
        opa += opaDlt;
        node.s('shape3d.opacity',opa)
        node.a('float', float);
    }
}

我們看下效果

到這,我們所有的動畫就已經寫完了。還等什麼呢,一起來創建一個屬於你自己心中理想的智能化城市吧

(ps: 不僅如此,HT官網中 還包含了數百個工業互聯網 2D 3D 可視化應用案例,點擊這裏體驗把玩:www.hightopo.com/demos/index…)

發佈了299 篇原創文章 · 獲贊 133 · 訪問量 37萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章