前言html
自 2011 年我國城鎮化率首次突破 50% 以來,《新型城鎮化發展規劃》將智慧城市列爲我國城市發展的三大目標之一,並提出到 2020 年,建成一批特點鮮明的智慧城市。截至現今,全國 95% 的副省級以上城市、76% 的地級以上城市,總計約 500 多個城市提出或在建智慧城市。vue
基於這樣的背景,本系統採用 Hightopo 的 HT for Web 產品來構造輕量化的 智慧城市 3D 可視化場景,經過三個角度的轉換,更清晰讓咱們感知到 5G 時代下數字化智能城市的魅力node
預覽地址:HT 智慧城市json
總體預覽圖canvas
第一個視角下,城市以市中心爲圓心緩緩浮現,市中心就如同整座城的大腦設計模式
第二個視角下,在樓房間穿過,細緻的感覺這城市的面貌數組
第三個視角下,鳥瞰整座城,體會智慧城市帶來的難以想象的欣喜promise
是否是以爲有些神奇,咱們接下來就是對項目的具體分析,手把手教你如何搭建一個本身心中的夢想城市緩存
場景搭建mvc
該系統中的大部分模型都是經過 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…)