前言
在當代社會,故宮已經成爲一個具有多元意義的文化符號,在歷史、藝術、文化等不同領域發揮着重要的作用,在國際上也成爲能夠代表中國文化甚至中國形象的國際符號。近幾年故宮的觀衆接待量逐年遞增,年接待量已突破千萬,根據故宮的文物特點與開放模式,必須及時建立一套完整的集監控與防患應急於一體的現代化監控系統。
故宮人流量動態監控系統採用 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 感興趣的夥伴給我留言,或者直接訪問官網查詢相關的資料。