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

圖撲軟體發表於2020-04-06

前言

自 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…)

相關文章