基於 HTML5 WebGL 的樓宇智慧化集成系統(一)
- 2020 年 4 月 7 日
- 筆記
// 創建二維拓撲視圖 this.g2d = new ht.graph.GraphView(); this.g2dDm = this.g2d.dm(); // 創建三維拓撲視圖 this.g3d = new ht.graph3d.Graph3dView(); this.g3dDm = this.g3d.dm(); // 將二維圖紙嵌入到三維場景中 this.g2d.addToDOM(this.g3d.getView()); // 修改左右鍵交互方式 let mapInteractor = new ht.graph3d.MapInteractor(this.g3d); this.g3d.setInteractors([mapInteractor]); // 修改最大仰角為 PI / 2 mapInteractor.maxPhi = Math.PI / 2; const G = {}; window.G = G; // 事件派發 G.event = new ht.Notifier();
3D 場景載入主視圖為:
首先我搭建了一個 3D 的場景用來放置我們的 json 場景數據,利用 ht.Default.xhrLoad 函數解析 json 場景數據,並通過 deserialize 將反序列化的對象加入DataModel來顯示載入 3D 場景,有興趣的可以通過<HT的序列化手冊>來了解這一機制的實現。
ht.Default.xhrLoad('scenes/demo.json', (json) => { if (!json) return; g3dDm.deserialize(json); // 設置三維視圖的中心點和相機位置 g3d.setCenter([-342, -64, 389]); g3d.setEye([-355, 10833, 2642]); // 設置最遠距離 g3d.setFar(1000000); // 獲取球圖標,設置為天空球 let skybox = g3dDm.getDataByTag('skyBox'); g3d.setSkyBox(skybox); // 模型載入完後執行動畫 const modelList = []; g3dDm.each(d => { const shape3d = d.s('shape3d'); if (!shape3d || !shape3d.endsWith('.json')) return; if (ht.Default.getShape3dModel(shape3d)) return; modelList.push(shape3d); }); ht.Default.handleModelLoaded = (name, model) => { const index = modelList.indexOf(name); if (index < 0) return; modelList.splice(index, 1); if (modelList.length > 91) return; ht.Default.handleModelLoaded = () => { }; // 模型載入完侯,默認執行場景切換動畫 g3d.moveCamera([257, 713, 1485], [7, 40, 144], { duration: 2000, finishFunc: () => { this.load2D(); } }); }; });
2D 面板載入視圖為:
同樣,我搭建了一個 2D 的場景用來放置我們的 json 矢量圖,利用 ht.Default.xhrLoad 函數將 json 矢量背景圖反序列化顯示在 2D 面板數據。
ht.Default.xhrLoad('displays/demo.json', (json) => { if (!json) return; g2dDm.deserialize(json); // 面板動畫入口 this.tittleAnim(); this.panelTime(); // 2D圖紙載入完後執行事件處理 this.loaded2DHandler(); });
二、3D 動畫效果以及切換漫遊
對於 3D 建模下的樓宇建築,加上場景的全方位漫遊,可使用戶達到一種沉浸式的體驗,更加直觀地去感受這個樓宇下各個場景的聯繫,依次地介紹了冷站、智慧末端以及熱站的位置以及功能運作的動畫 。主要運用的方法是通過藉助 HT 提供的 ht.Shape 圖元類型,可以在 GraphView 和 Graph3dView 組件上展示出各種二維和三維的形狀效果,而漫遊的管道路線就是由其擴展子類 ht.Polyline 去繪製實現一條三維的管道,然後用這條繪製的管道加上漫遊的時間去調用這個漫遊的方法,其本質上是圍繞著中心點,然後根據管道去不斷地改變視角下的 eye 和 center 的數值,達到環視這個建築的整體視角。
這裡可以了解一下關於空間軌道的繪製,詳見<HT的形狀手冊>的空間管線章節。
以下是環視漫遊動畫的偽程式碼:
polyLineRoam(polyLine, time) { const g3d = this.g3d; const g3dDm = this.g3dDm; this.roamButton.a('active', true); this.roamAnim = ht.Default.startAnim({ duration: time, easing: t => t, action: (v, t) => { let length = this.main.g3d.getLineLength(polyLine), offset = this.main.g3d.getLineOffset(polyLine, length * v), point = offset.point, px = point.x, py = point.y, pz = point.z; g3d.setEye(px, py, pz); g3d.setCenter(7, 40, 144); }, finishFunc: () => { this.roam1(); } }); }
在整體建築的環視漫遊完後,我們可以通過拉近各個場景的視角,來依次巡視各個場景所執行的動畫。在根據管道改變 eye 和 center 環視漫遊方法結束後,用動畫的結束回調 finishFunc 去調用下一個動畫的執行,而巡視漫遊就在這裡去調用,以下我們以巡視冷站的漫遊動畫為例去介紹實現的方法。
巡視漫遊的主要實現方法是通過 HT 核心包的相機移動 moveCamera 來實現的, 通過參數 (eye, center, animation) 來調用這個方法:
- eye:新的相機位置,形如[-291, -8, 283],如果為 null 則使用當前相機的位置;
- center:新的目標中心點位置(相機看向的位置),形如[148, -400, 171],如果為 null 則使用當前中心點位置;
- animation:默認 false,是否啟用動畫,可以設置為 true 或者 flase 或者 animation 動畫對象;
每次執行完一個場景的視角移動後,再通過相機移動動畫的結束回調 finishFunc 調用下一個相機移動的動畫,達到巡視漫遊的效果。
// 切換到冷站視角 roam1() { const g3d = this.g3d; const g3dDm = this.g3dDm; this.roamAnim = g3d.moveCamera([-291, -8, 283], [148, -400, 171], { duration: 500, easing: t => t * t, finishFunc: () => { this.roam2(); } }); }
在環視漫遊和巡視漫遊的執行下,我們也可以觸發 2D 圖紙右面板下的按鈕面板去觀看我們想要瀏覽的指定場景,這時候就會關閉當前在執行的環視漫遊或者巡視漫遊,再次點擊改按鈕則返回場景的主視角,或者點擊左上角漫遊按鈕又可以進入環視漫遊,這樣的交互體驗,可以方便用戶即使地查看想要瀏覽的場景,而不用依靠等待逐一漫遊下去查看,也不會干擾到漫遊的整體體驗。相應地通過介紹冷站按鈕的點擊觸發介紹一下實現的方法。
一般的交互方式存在三種事件交互的方法,包括事件通知管理器 ht.Notifier 類,內置的 Interator 在交互過程會派發出事件和數據綁定的監聽來實現,而這裡使用的是第三種交互方式。
通過數據綁定監聽到 onDown 執行按下的事件後,通過改變按下和再次按下的按鈕狀態 active 來分別執行相機移動去切換視角,主要實現的偽程式碼如下:
// 設置圖元可交互 this.coolingCentralStationButton.s('interactive', true); // 通過數據綁定監聽到onDown執行按下的事件 this.coolingCentralStationButton.s('onDown', () => { // 切換到冷站時,2d面板所執行的切換動畫 this.switchToColdStation(); // 按鈕初始化 this.buttonTearDown(); // 按鈕按下效果的狀態 let active = this.coolingCentralStationButton.a('active'); // button為按鈕集合數組,當按下電梯按鈕,其他按鈕默認false button.forEach(btn => { btn.a('active', false); }); // 冷站按鈕的狀態切換 this.coolingCentralStationButton.a('active', !active); // 根據冷站按鈕的狀態執行切換到冷站或者切換回主視角 if (active) { // 相機移動切換到主視角 moveCamera(g3d, [257, 713, 1485], [7, 40, 144], { duration: 2000, easing: t => t * t }); } else { // 漫遊動畫對象如果不為空,則暫停漫遊動畫對象並且設置為空 if (this.roamAnim !== null) { this.roamAnim.pause(); this.roamAnim = null; } // 相機移動切換到冷站視角 coolingCentralStationAnimation = moveCamera(g3d, [-291, -8, 283], [148, -400, 171], { duration: 2000, easing: t => t * t }); } });
當然,在 3D 場景下還有一些很有趣的動畫效果,比如車流效果、飛光效果和圓環擴散效果。車流效果主要通過採用了貼圖的 uv 的偏移來實現達到車流穿梭的科技感效果;而飛光效果則是採用調度動畫的方法來間隔設置飛光的高度,達到最高點則消失然後重新輪迴動畫展示;圓環擴散效果則是同樣採用調度動畫的方法來間隔設置圓環的縮放值和透明度,來達到擴散消失的效果。
對於間隔的調度動畫,為了實現動畫的流暢性,這裡調度使用的 loop 是運用到自己封裝 HT 的動畫 ht.Default.startAnim 的一個方法:
- frames 動畫幀數,這裡不鎖定幀數,可以適應本身動畫的幀數;
- interval 動畫間隔,單位ms,默認設置20ms。
loop(action, interval = 20) { return ht.Default.startAnim({ frames: Infinity, interval: interval, action: action }); }
然後通過調用這個 loop 的間隔動畫方法,我們來實現車流效果、飛光效果和圓環擴散效果,實現的參考偽程式碼如下:
// 車流圖元的初始化 let traffic = g3dDm.getDataByTag('traffic'); // 圓環擴散圖元的初始化 let lightRing = this.lightRing = g3dDm.getDataByTag('lightRing'); // 飛光圖元設置三種透明狀態數組集合flyMap的初始化 [1, 2, 3].forEach(i => { const data = flyMap['fly' + i] = g3dDm.getDataByTag('fly' + i); data.eachChild(d => { d.s({ // 打開透明度 'shape3d.transparent': true, // 根據不同的數組集合設置不同的透明度 'shape3d.opacity': i === 3 ? 0.5 : 0.7, // 設置沿著y軸自動旋轉 'shape3d.autorotate': 'y' }); }); }); if (this.flyAnim) return; this.flyAnim = loop(() => { // 飛光根據間隔設置高度來達到上升的效果 for (let k in flyMap) { const data = flyMap[k]; let e = data.getElevation() + flyDltMap[k]; if (e >= 500) e = -400; data.setElevation(e); } // 車流根據設置間隔增長uv偏移量來實現穿梭的效果 traffic.eachChild(c => { c.s('all.uv.offset', [location, 0]); }); location -= 0.03; // 旋轉震蕩波透明度漸降 let percent = lightRing.a('percent') || 0, scale = 15 * percent + 0.5; lightRing.setScale3d([scale + 1, scale, scale + 1]); lightRing.s('shape3d.opacity', (1 - percent) * 0.5); percent += 0.01; if (percent >= 1) { percent = 0; } lightRing.a('percent', percent); }, 50);
三、冷站場景和熱站場景的動畫實現
場景動畫中機組的風扇、集水器的蓄滿以及水的流動效果:
動畫的實現主要還是通過 HT 自帶的 ht.Default.startAnim 動畫
函數,支援 Frame-Based 和 Time-Based 兩種方式的動畫。同樣的,我們這裡使用的是 Frame-Based 來封裝一個 loop 函數來執行每一幀間隔的動畫。
一般來說,動畫可通過自行配置來達到自己想要實現的方法,這裡可以了解< HT 的入門手冊>關於動畫
函數的介紹。
if (this.stationAnim) return; this.stationAnim = loop(() => { // 冷站水管流動 coldFlow_blue.eachChild(c => { c.s('shape3d.uv.offset', [-location, 0]); }); coldFlow_yellow.eachChild(c => { c.s('shape3d.uv.offset', [location, 0]); }); // 熱站水管流動 heatFlow_blue.eachChild(c => { c.s('shape3d.uv.offset', [-location, 0]); }); heatFlow_yellow.eachChild(c => { c.s('shape3d.uv.offset', [location, 0]); }); location -= 0.03; // 冷站風扇旋轉 cold_fan.eachChild(c => { c.setRotation3d(c.r3()[0], c.r3()[1] + (Math.PI / 10), c.r3()[2]); }); // 熱站風扇旋轉 heat_fan.eachChild(c => { c.setRotation3d(c.r3()[0], c.r3()[1] + (Math.PI / 10), c.r3()[2]); }); // 集水器水位變化 HotWaterTankTall += 0.25; if (HotWaterTankTall > 15) { HotWaterTankTall = 0; } coldWaterTankTall1 += 0.25; if (coldWaterTankTall1 > 20) { coldWaterTankTall1 = 0; } coldWaterTankTall2 += 0.25; if (coldWaterTankTall2 > 20) { coldWaterTankTall2 = 0; } hotWaterTank.setTall(HotWaterTankTall); coldWaterTank1.setTall(coldWaterTankTall1); coldWaterTank2.setTall(coldWaterTankTall2); }, 50);
四、中央空調末端智慧群控系統場景效果
這裡採用了模擬數據的方式來體現末端智慧節能控制的效果。應用於真實項目的時候,可以採用數據介面的方式來實時對接真實數據,可以達到實時監控的效果。
我使用了自己 mock 的末端群控的數據參數,格式如下:
var boxData = [ [{ // 設備編號 id: 'box1', // 設備的溫度 temperature: 23.8, // 設備的頻率 frequency: 45.8 }, ...] ... ];
這裡的實現也是通過 loop 循環執行數據的讀取,當數組指標 index 讀取到最後一個數據時,立即關閉循環並清空 loop調度。
boxAnimation = loop(() => { for (let i = 0, l = 16; i <= l-1; i++) { let roomTag, roomBox, tag; tag = i+1; roomTag = 'boxPanel' + tag; roomBox = 'box' + tag; let panel = g3dDm.getDataByTag(roomTag); let box = g3dDm.getDataByTag(roomBox); if (panel) { panel.a('valueT', boxData[index][i].temperature + '℃'); panel.a('valueK', boxData[index][i].frequency + 'Hz'); // 手動更新快取的面板資訊 g3d.invalidateShape3dCachedImage(panel); // 根據溫度判斷設備的顏色 if (box && parseFloat(panel.a('valueT')) < 26) { box.s('shape3d.blend', 'rgb(4,67,176)'); box.s('wf.color', 'rgb(4,67,176)'); } else if (box && parseFloat(panel.a('valueT')) >= 26 && parseFloat(panel.a('valueT')) <= 28) { box.s('shape3d.blend', 'rgb(28,189,87)'); box.s('wf.color', 'rgb(28,189,87)'); } else if (box && parseFloat(panel.a('valueT')) > 28) { box.s('shape3d.blend', 'rgb(181,43,43)'); box.s('wf.color', 'rgb(181,43,43)'); } } } index++; if (index >= 10) { boxAnimation.pause(); boxAnimation = null; } }, 500);
總結