基於 HTML5 WebGL 的故宮人流量動態監控系統
前言
在當代社會,故宮已經成為一個具有多元意義的文化符號,在歷史、藝術、文化等不同領域發揮著重要的作用,在國際上也成為能夠代表中國文化甚至中國形象的國際符號。近幾年故宮的觀眾接待量逐年遞增,年接待量已突破千萬,根據故宮的文物特點與開放模式,必須及時建立一套完整的集監控與防患應急於一體的現代化監控系統。
故宮人流量動態監控系統採用 Hightopo 的 HT for Web 產品來構造 故宮 3D 動態視覺化場景,通過將現場部署的感測器、監控裝置等裝置與智慧聯網裝置整合到網際網路上,對故宮當前的人流狀態、人流擁擠度進行實時監測,並生成人流量熱力圖直觀的展示現場人流資料,以預防擁擠、踩踏等意外事故的發生。
預覽地址:故宮人流量動態監控系統
整體預覽圖:
全景圖預覽:
程式碼實現
建立場景
專案目錄結構如下:
index.js 是 src 下的入口檔案,建立了一個 由 main.js 中匯出的 Main 類,Main 類中建立 3D 元件和 2D 元件,利用 g2d.deserialize() 方法將 json 向量背景圖反序列化顯示在 2D 元件上並利用 this.load() 方法進行 3D 場景的載入工作,在 Main 類中使用了 HT 自帶的事件派發器,this.event.fire() 和 this.event.add() 分別是派發事件和訂閱事件,在本示例中通過事件訂閱與派發完成3D場景的切換效果,關鍵程式碼如下:
import util from '../util/util'; import forbiddenCity from './forbiddenCity.js' import heatMap from './heatMap.js' import loadScene from './loadScene.js' class Main { constructor() { let g3d = (this.g3d = new ht.graph3d.Graph3dView()); this.g3dDm = this.g3d.dm(); let g2d = (this.g2d = new ht.graph.GraphView()); this.g2dDm = this.g2d.dm(); //將 3D 元件加入到 body 下 g3d.addToDOM(); // 將 2D 元件加入到 3D 元件的根 div 下,父子 DOM 事件會冒泡,這樣不會影響 3D 場景的互動 g2d.addToDOM(g3d.getView()); // 初始化場景 this.init(); } init() { // 2D面板載入 this.g2d.deserialize('displays/htproject_2019_q4/故宮/首頁.json', (json, dm, g2d, datas) => {
}); this.forbiddenCity = new forbiddenCity(this); this.heatMap = new heatMap(this); // 首頁3D場景載入 this.load(this.forbiddenCity); // 訂閱事件 this.addListener(e => { if (e.type === 'loadforbiddenCity') { this.load(this.forbiddenCity); } else if (e.type === 'loadheatMap') { this.load(this.heatMap); } }); } load(scene) { let old = this.activeScene; if (old) { old.tearDown(); } this.activeScene = scene; scene.setUp(); } fire(e) { this.event.fire(e); } addListener(cb, scope) { this.event.add(cb, scope); } } export default Main;
由上可以看出在 Main 類中我們通過訂閱事件提供了場景切換的程式碼,即通過呼叫兩個場景檔案中的 setUp() 方法來完成 3D 場景的切換讓我們來看下在 forbiddenCity.js 與 heatMap.js 中是如何進行場景切換的:
setUp() { let g3d = this, dm3d = g3d.dm(); super.setUp(); util.setSceneLevel('forbiddenCity'); // 清空資料容器 dm3d.clear(); // 反序列化 3D 圖紙 g3d.deserialize('scenes/htdesign/city/故宮/故宮.json', (json, dm, g3d, datas) => { }); }
setUp() { let g3d = this, dm3d = g3d.dm(); super.setUp(); util.setSceneLevel('heatMap'); // 清空資料容器 dm3d.clear(); // 反序列化 3D 圖紙 g3d.deserialize('scenes/htdesign/city/故宮/熱力圖.json', (json, dm, g3d, datas) => { }); }
以上程式碼可以看出我們在每次切換場景時都會呼叫資料容器的 clear() 方法來清空資料然後再呼叫 g3d.deserialize() 方法反序列化載入新場景圖紙,從而完成新舊場景的載入和清空。
投影實現
為增強 3D 場景的立體感,在最新版本的 HT 核心包中新增了場景投影效果配置函式,使用者通過呼叫 enableShadow() 和 disableShadow() 方法可以實現開啟關閉 3D 投影效果,此外還可以通過設定 node.s('shadow.cast', false) 對部分不需要投影的模型進行投影關閉處理,投影關鍵程式碼:
import util from '../util/util'; const loadScene = { shadow(g3d) { var ssc = function(filter) { var nodes = g3d.dm().toDatas(filter); if (!nodes.length) { return; }; nodes.each(function(node) { node.s('shadow.cast', false); }); } var nameFilter = function(name) { return function(node) { return node.getDisplayName() === name; } } var typeFilter = function(type) { return function(node) { return node.s('shape3d') === type; } } ssc(nameFilter('路線')); ssc(nameFilter('佈景')); ssc(nameFilter('燈光')); ssc(typeFilter('models/醫療/陰影_1.json')); ssc(typeFilter('models/醫療/地面.json')); ssc(typeFilter('models/htdesign/Identification/point/riangle_01.json')) // 為了編組用的 box ssc(typeFilter('box')); if (util.getSceneLevel() === 'forbiddenCity') { g3d.enableShadow({ // 投影 x 軸角度 degreeX: 55, // 投影 z 軸角度 degreeZ: -35, // low / medium / high / ultra / 4096數值 quality: 4096, // 深度浮點偏差補足 bias: -0.0003, // none / hard / soft type: 'soft', // type 為 hard / soft 時,補充的邊緣厚度,用來提供更柔和的邊緣 radius: 1.0, // 陰影強度, 1 為黑色 intensity: 0.45 }); g3d.iv(); } } } export default loadScene
動畫實現
飛鳥動畫
飛鳥動畫可以拆分為兩個步驟:1.飛鳥沿固定路線環繞故宮的飛行動作以及上下位置變化動作,2.飛鳥自身的翅膀扇動動作。我們使用 HT 自帶的 ht.Default.startAnim 函式讓飛鳥模型沿著三維空間管道做週期運動,在動畫中定義了一個變數 count 每次動畫都遞增,通過 Math.cos(count % 36 * 10 * Math.PI / 180) 函式使值在 1 和 -1 之間做週期變化,配合 setRotationZ() 方法改變翅膀在 3D 拓撲中沿 z 軸的旋轉角度從而達到飛鳥翅膀上下扇動,關鍵程式碼如下:
// 飛鳥動畫 flyerAnim(g3d) { const dm3d = g3d.dm(); let polyline = dm3d.getDataByTag('polyline'); let flyers = dm3d.getDataByTag('flyers'); let count = 0; let radomArr = [this.random(20, 80), this.random(30, 100), this.random(10, 60), this.random(10, 50), this.random(5, 20), this.random(20, 70) ]; if (polyline) { let anim = { // 動畫週期毫秒數 duration: 40000, easing: function(t) { return t; }, action: (v, t) => { if (util.getSceneLevel() !== 'heatMap' && polyline) { let length = g3d.getLineLength(polyline); // 獲取三維空間管道座標 if (length) { let offset = g3d.getLineOffset(polyline, length * v), point = offset.point, tangent = offset.tangent, px = point.x, py = point.y, pz = point.z, tx = tangent.x, ty = tangent.y, tz = tangent.z; flyers.eachChild((bird, index) => { let ty = bird.getTag().split('_')[1]; let positionZ = pz + index * 50 + radomArr[index] / 3, positionX = px + (index - 3) * 50 + radomArr[index] / 3, positionY = py + radomArr[index] / 5; if (index > 2) positionZ = pz - (index - 6) * 50 + radomArr[index] / 3; // 設定飛鳥翅膀扇動動畫 const pos = count + index, pos2 = count - index * 6; if (pos2 > 0) { if (!bird._posId) bird._posId = pos2; bird._posId++; if (bird._posId > index * 100 + 500 && bird._posId < index * 100 + 600) { bird.eachChild((child) => { if (child.getTag() === 'wingLeft') { child.setRotationZ(0); } else if (child.getTag() === 'wingRight') { child.setRotationZ(0); } }); if (bird._posId === index * 100 + 599) bird._posId = 1; } else { bird.eachChild((child) => { if (child.getTag() === 'wingLeft') { child.setRotationZ(child.r3()[2] + Math.cos(bird._posId % 36 * 10 * Math.PI / 180) * 4 * 0.03); } else if (child.getTag() === 'wingRight') { child.setRotationZ(child.r3()[2] - Math.cos(bird._posId % 36 * 10 * Math.PI / 180) * 4 * 0.03); } }); } } // 設定飛鳥飛行軌道動畫 bird.p3(positionX + radomArr[index] * v, positionY + radomArr[index] * v + Math.cos(count % 36 * 10 * Math .PI / 180) * ty * 5, positionZ + radomArr[index] * v); // 設定飛鳥朝向位置 bird.lookAt([positionX + radomArr[index] * v + tx, positionY + ty + radomArr[index] * v, positionZ + radomArr[index] * v + tz ]); }) count++; } } }, finishFunc: function() { // 繼續執行飛鳥管道動畫 this.birdAnim = ht.Default.startAnim(anim); } }; if (util.getSceneLevel() === 'forbiddenCity') { // 執行飛鳥管道動畫 this.birdAnim = ht.Default.startAnim(anim); } } }
鳥瞰漫遊動畫
在飛鳥動畫實現的前提下,接下來我們可以進一步以飛鳥模型為中心來生成鳥瞰漫遊動畫。首先使用 ht.Default.startAnim 函式實時呼叫飛鳥所在位置,通過 setEye() 和 setCenter() 方法動態設定場景的中心點和相機位置,以此達到從飛鳥的視角俯瞰整個故宮場景的動畫效果。關鍵程式碼如下:
// 鳥瞰漫遊動畫 roamingAnim() { const g3d = this.g3d; let flyers = g3d.dm().getDataByTag('flyers'); let anim = { duration: 60000, // 動畫週期毫秒數 easing: function (t) { return t * t; }, action: function (v, t) { let flyersP = flyers.p3(); let px = flyersP[0]; let py = flyersP[1]; let pz = flyersP[2]; g3d.setEye(px, py + 50, pz - 400); g3d.setCenter(px, py, pz); } } this.roaming = ht.Default.startAnim(anim); }
景深動畫
在 HT for Web 中為 3D 元件提供了 enablePostProcessing() 方法,使用者可以通過呼叫該方法手動開啟 3D 場景的景深模糊效果,另外還可以通過設定 aperture 屬性改變景深模糊度,在本示例中通過動態改變 aperture 屬性形成淡入淡出效果以減少場景切換時的突兀感,關鍵程式碼如下:
// 景深動畫 depthAnim(g3d, x = 0) { let dof = g3d.getPostProcessingModule('Dof'); // 景深開啟 g3d.enablePostProcessing('Dof', true); return new Promise((resolve, reject) => { let anim = { duration: 1000, easing: (t) => { return t * t; }, action: (v, t) => { // 動態設定景深閾值 dof.aperture = x - v * 0.02 if (v == 1) resolve('end'); } } ht.Default.startAnim(anim); }) }
主要功能
人流量熱力圖
熱力圖以特殊高亮的形式顯示遊客所在的地理區域的圖示,可以非常直觀的展示人流量密度資訊。本示例中使用 HT 封裝的 ht.thermodynamic.Thermodynamic3d() 方法動態生成熱力圖,關鍵程式碼如下:
createHeatMap(heatMapName, num) { const g3d = this.g3d; const dm3d = g3d.dm(); let room = dm3d.getDataByTag(heatMapName); // 獲取要生成熱力圖的矩形區域 let heatRect = room.getRect(); let Vector3 = ht.Math.Vector3; let tall = 30 let { x, y, width, height } = heatRect; if (width === 0 || height === 0) return let templateList = []; // 在熱力圖區域隨機生成 num 個熱力點位 for (let index = 0; index < num; index++) { templateList.push({ position: { x: this.random(0, heatRect.width), y: this.random(0, heatRect.height), z: tall }, temperature: { value: 30 + this.random(0, 20), radius: 90 }, }) } // 熱力圖初始化 let thd = window.thd = new ht.thermodynamic.Thermodynamic3d(g3d, { box: new Vector3(width, height, tall), min: 15, max: 55, interval: 200, remainMax: false, opacity: 0.1, colorStopFn: function (v, step) { return v * step * step }, gradient: { 0: 'rgba(0,162,255,0.14)', 0.2: 'rgba(48,255,183,0.3)', 0.4: 'rgba(255,245,48,0.5)', 0.6: 'rgba(255,73,18,0.9)', 0.8: 'rgba(217,22,0,0.95)', 1: 'rgb(179,0,0)', } }); thd.setData(templateList); // 建立熱力圖 let node = thd.createThermodynamicNode(2, 2, 50); node.setAnchorElevation(0); node.setTag('test'); node.p3(room.p3()); node.s({ '3d.selectable': false, '3d.movable': false, 'wf.visible': false, 'shape3d.transparent': true, }); dm3d.add(node); }
這裡簡單的描述下熱力圖生成步驟:1.首先確定熱力圖生成區域,在該區域內獲取感測器位置和熱力資訊,並將這些資訊儲存在 templateList 陣列中。2.將陣列傳入 Thermodynamic3d() 方法中並配置漸變顏色、透明度等相關資訊生成熱力圖渲染資料。3.使用 createThermodynamicNode() 方法按照熱力圖渲染資料建立熱力圖。4.將熱力圖新增到資料容器中。
視訊監控
我們通過 addInteractorListener 互動監聽器為場景中攝像頭模型繫結點選事件,每個攝像頭都對應一個監控視訊畫面,通過點選彈出或關閉,並對視窗中顯示的監控畫面數量進行了限制,不得超過 4 個否則將不會繼續彈出監控畫面,避免顯示多個畫面造成場景遮擋,關鍵程式碼如下:
videoVisible(videoName) { let g2d = this.g2d, dm2d = g2d.dm(); // 當前選中監控畫面 const video = dm2d.getDataByTag(videoName); if (video) { const videoList = video.getParent(); const videoRect = video.getRect(); const visible = g2d.isVisible(video); if (visible) { // 隱藏選中監控畫面,並重新排列監控畫面 this.hideVideo(videoList, video, videoRect); } else { // 顯示選中監控畫面,並重新排列監控畫面 let showVideos = []; videoList.eachChild(child => { g2d.isVisible(child) && child !== video && showVideos.push(child) }) if (showVideos.length < 5) { video.s('2d.visible', true); video.setY(util.getVideoListRect().y + (videoRect.height + 5) * showVideos.length); } } } } hideVideo(parent, video, videoRect) { parent.eachChild(node => { const nodeRect = node.getRect(); if (nodeRect.y > videoRect.y) { node.setY(nodeRect.y - nodeRect.height) } }) video.s('2d.visible', false) }
總結
現如今,伴隨國民經濟的持續高速增長,旅遊行業迎來了健康發展的階段,各大景區每年接待的遊客人數都在不斷增長,如果不對人流量進行控制的話將會出現許多隱患。本次示例效果均採用 HT 提供的 api 進行程式碼開發,旨在定製一套以人流量監測為中心的集監控與防患應急於一體的景點 3D 實時監控系統,也歡迎對 HT 感興趣的夥伴給我留言,或者直接訪問 官網 查詢相關的資料。