基于 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 感兴趣的伙伴给我留言,或者直接访问 官网 查询相关的资料。