基于 HTML5 WebGL 构建 3D 智能数字化城市全景
前言
自 2011 年我国城镇化率首次突破 50% 以来,《新型城镇化发展规划》将智慧城市列为我国城市发展的三大目标之一,并提出到 2020 年,建成一批特色鲜明的智慧城市。截至现今,全国 95% 的副省级以上城市、76% 的地级以上城市,总计约 500 多个城市提出或在建智慧城市。
基于这样的背景,本系统采用 Hightopo 的 HT for Web 产品来构造轻量化的 智慧城市 3D 可视化场景,通过三个角度的转换,更清晰让我们感知到 5G 时代下数字化智能城市的魅力
预览地址:HT 智慧城市
整体预览图
第一个视角下,城市以市中心为圆心缓缓浮现,市中心就如同整座城的大脑
第二个视角下,在楼房间穿过,细致的感受这城市的面貌
第三个视角下,鸟瞰整座城,体会智慧城市带来的不可思议的欣喜
是不是觉得有些神奇,我们接下来就是对项目的具体分析,手把手教你如何搭建一个自己心中的梦想城市
场景搭建
该系统中的大部分模型都是通过 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 场景,如下
1 import event from '../util/NotifierManager'; 2 import Index3d from './3d/Index3d'; 3 import { INDEX, EVENT_SWITCH_VIEW } from '../util/constant'; 4 5 export default class Main { 6 constructor() { 7 let g3d = this.g3d = new ht.graph.Graph3dView(), 8 9 //将3d图纸添加到dom对象中 10 g3d.addToDOM(); 11 12 this.event = event; 13 //创建一个Index3d类,作为场景初始化 14 this.index3d = new Index3d(g3d); 15 //调用switch方法派发EVENT_SWITCH_VIEW事件,并传入事件类型 INDEX 16 this.switch(INDEX); 17 } 18 switch(key = INDEX) { 19 event.fire(EVENT_SWITCH_VIEW, key); 20 } 21 // 22 }
我们用 new ht.graph.Graph3dView() 的方式创建了一个 3D 画布,画布的顶层是 canvas 。并创建了一个 index3d 对象,看到后面我们就能知道其实这一步就如同我们把场景“画”上去。在 main 对象中我们还引用了 util 下的 NotifierManager 文件,这个文件中的 event 对象为穿插在整个项目中事件总线,使用了 HT 自带的事件派发器,可以很方便的手动的订阅事件和派发事件,感兴趣可以进一步了解 HT 入门手册 ,下面便是文件内容
1 class NotifierManager { 2 constructor() { 3 this._eventMap ={}; 4 } 5 6 add(key, func, score, first = false) { 7 let notify = this._eventMap[key]; 8 if (!notify) notify = this._eventMap[key] = new ht.Notifier(); 9 10 notify.add(func, score, first); 11 } 12 13 remove(key, func, score) { 14 const notify = this._eventMap[key]; 15 if (!notify) return; 16 17 notify.remove(func, score); 18 } 19 20 fire(key, e) { 21 const notify = this._eventMap[key]; 22 if (!notify) return; 23 24 notify.fire(e); 25 } 26 } 27 28 const event = new NotifierManager(); 29 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 类的实现
1 import event from "../util/NotifierManager"; 2 import util from '../util/util'; 3 import { EVENT_SWITCH_VIEW } from "../util/constant"; 4 5 export default class View { 6 constructor(view) { 7 this.url = ''; 8 this.key = ''; 9 this.active = false; 10 this.view = view; 11 this.dm = view.dm(); 12 13 event.add(EVENT_SWITCH_VIEW, (key) => { 14 this.handleSwitch(key); 15 }); 16 } 17 handleSwitch(key) { 18 if (key === this.key) { 19 if (!this.active) { 20 this.active = true; 21 this.onUp(); 22 } 23 this.dm.clear(); 24 util.deserialize(this.view, this.url, this.onPostDeserialize.bind(this)); 25 } 26 // 目前是这个场景,执行 tearDown 27 else if (this.active) { 28 this.onDown(); 29 this.active = false; 30 } 31 } 32 /** 33 * 加载这个场景前调用 34 */ 35 onUp() { 36 } 37 /** 38 * 离开这个场景时会调用 39 */ 40 onDown() { 41 } 42 /** 43 * 加载完场景处理 44 */ 45 onPostDeserialize() { 46 console.log(this) 47 } 48 49 }
其它内容我们就不做过多阐述了,主要说一下我们加载场景使用的 deserialize 方法,我们打开 util 下的 util 文件找到这个方法
1 deserialize: (function() { 2 let cacheMap = {}; 3 /** 4 * 加载 json 并反序列化 5 * 6 */ 7 return function(view, url, cb, notUseCache) { 8 let json, cache = !notUseCache; 9 if (!notUseCache) { 10 json = cacheMap[url]; 11 } 12 else { 13 cache = false; 14 } 15 // 不使用缓存,重新加载 16 view.deserialize(json || url, (json, dm, view, list) => { 17 cacheMap[url] = json; 18 cb && cb(json, dm, view, list, cache);19 } 20})()
其中的 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
1 moveEyeAction: function(g3d,moveEyeConfig,cover){ 2 if (!moveEyeConfig) return; 3 let moveEye = function(obj,time,eas = 'liner'){ 4 return new Promise((res,rej) => { 5 g3d.setEye(obj.initEye); 6 g3d.setCenter(obj.initCenter); 7 g3d.moveCamera(obj.moveEye,obj.moveCenter, { 8 duration:time, 9 easing: function(t){ 10 if(t < 0.5){ 11 cover(t,'up'); 12 } 13 if (eas === 'ease-in'){ 14 return t * t; 15 } 16 else if (eas === 'liner'){ 17 return t 18 } 19 else { 20 return t 21 } 22 }, 23 finishFunc: ()=>{ 24 cover(1,'down'); 25 res(time); 26 } 27 }); 28 }) 29 } 30 31 moveEye(moveEyeConfig[0],moveEyeConfig[0].time,moveEyeConfig[0].eas) 32 .then((res)=>{ 33 console.log(1) 34 return moveEye(moveEyeConfig[1],moveEyeConfig[1].time,moveEyeConfig[1].eas) 35 }) 36 .then((res)=>{ 37 moveEye(moveEyeConfig[2],moveEyeConfig[2].time,moveEyeConfig[2].eas) 38 )}
39})
我们在函数中创建了一个方法 moveEye,它创建并返回了一个 promise ,方便我们做回调,防止出现回调地狱的情况。然后我们只要提前先配置好每一步的视角,传入函数中,函数便会依次调用 g3d 上的 moveCamera 方法,在每一步动画结束的时候,调用 cover 函数作为过渡。
我们再来看一下 cover 函数的实现,在 3D 场景初始化时便会调用下方的 create2dCover 方法创建 cover,其实就是在最外层盖上了一层 div ,每一步动画结束的时候,根据传入的参数决定是否变暗完成过渡
1create2dCover(){ 2 let div = document.createElement("div"); 3 div.style.position = 'absolute'; 4 div.style.background = 'black'; 5 div.style.opacity = 0; 6 div.style.top = '0'; 7 div.style.right = '0'; 8 div.style.bottom = '0'; 9 div.style.left = '0'; 10 div.style.pointerEvents = 'none'; 11 document.body.appendChild(div); 12 let dire = 'up'; 13 let cover = function(t,direction,num){ 14 if (direction === 'up' && dire === 'down'){ 15 div.style.opacity = 1- t * 4; 16 if (t > 0.5) dire = 'up'; 17 } 18 if (direction === 'down' && dire === 'up'){ 19 if (t === 1) { 20 div.style.opacity = t; 21 dire = 'down'; 22 } 23 } 24 } 25 return cover; 26}
我们再来看一下动画效果
第一个视角下的建筑浮现动画
我们先看下 Index3d 类的实现,再加载完场景的时候,我们便会调用上面我们说过的视角转换函数 moveEyeAction , 和我们接下来要讲的城市浮现函数 upCityDemo。
1 onPostDeserialize(json, dm, view) { 2 const g3d = this.view; 3 g3d.setFar(100000); 4 const nodeUpArr1 = [], nodeUpArr2 = [], nodeUpArr3 = []; 5 //视角配置参数 6 const moveEyeConfig = [{ 7 initEye:[-700,390,-974], 8 initCenter:[-1596,25,-518], 9 moveEye:[-2572, 390, -974], 10 moveCenter:[-1596,25,-518], 11 time: 9000, 12 eas: 'ease-in' 13 },{ 14 initEye:[1500,71,900], 15 initCenter:[-1823,25,-636], 16 moveCenter:[-1823,25,-636], 17 moveEye:[-1678, 18, -558], 18 time:8000 19 },{ 20 initEye:[2491,600,-1026], 21 initCenter:[0,0,0], 22 moveEye:[-3105, 500, -1577], 23 moveCenter:[-1034, -12, -41], 24 time:8000 25 }] 26 //创建一个蒙板div并返回cover函数 27 let cover = this.create2dCover(); 28 //浮现城市的属性初始化 29 dm.each(fnode => { 30 //第一批楼房-市中心 31 if (fnode.getDisplayName() === "up1"){ 32 fnode.a('startE',fnode.getElevation()); 33 fnode.setElevation(-200); 34 nodeUpArr1.push(fnode); 35 } 36 //第二批城市-市中心附近建筑 37 if (fnode.getDisplayName() === "up2"){ 38 fnode.a('startE',fnode.getElevation()) 39 fnode.setElevation(-100); 40 nodeUpArr2.push(fnode); 41 } 42 //第三批城市-外围建筑 43 if (fnode.getDisplayName() === "up3"){ 44 fnode.a('startE',fnode.getElevation()) 45 fnode.setElevation(-100); 46 nodeUpArr3.push(fnode); 47 } 48 49 if(fnode.getDisplayName() === '飞光组'){ 50 fnode.eachChild(node => { 51 node.s('shape3d.opacity',0); 52 }) 53 } 54}) 55 56 //视角开始变换 57 util.moveEyeAction(g3d,moveEyeConfig,cover) 58 //城市浮现 59 let upCityDemo = function(nodeArr,time,T = 0.6){ 60 return new Promise((res,rej)=>{ 61 ht.Default.startAnim({ 62 duration:time, 63 action: (v,t) => { 64 nodeArr.forEach((node)=>{ 65 if(t > T) res('已完成'); 66 let org = node.getElevation(); 67 let tar = node.a('startE'); 68 node.setElevation(org + (tar - org) * v) 69 }) 70 } 71 }) 72 }) 73 } 74 75 upCityDemo(nodeUpArr1,11000,0.4).then((res)=>{ 76 // console.log(res) 77 return upCityDemo(nodeUpArr2,2000,0.4) 78 }).then((res)=>{ 79 return upCityDemo(nodeUpArr3,2000); 80 }).then((res)=>{ 81 //城市出现,开始动画 82 //this.startAnimation(g3d,dm); 83 }) 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 中,并通过控制它们不同的步数就能实现不一样的速度效果。
我们共有五个动画效果,旋转动画可以归为一类
· 建筑下的水波扩散动画
· 风车,建筑底下光圈旋转动画
· 道路偏移动画
· 市中心上方光线流动动画
· 建筑上面的数字飞光动画
1 ht.Default.startAnim({ 2 frames: Infinity, 3 interval: 20, 4 action: () => { 5 //扩散水波动画 6 waveScale(scaleList,dltScale,maxScale,minScale); 7 //风车旋转,建筑底下光圈旋转 8 rotationAction(roationFC,dltRoattion); 9 rotationAction(roationD,dltRoattionD); 10 rotationAction(roationD2,-dltRoattionD2); 11 //道路偏移 12 uvFlow(roadSmall,dltRoadSmall); 13 uvFlow(roadMedium,dltRoadMedium); 14 uvFlow(roadBig,dltRoadBig); 15 //光亮建筑下的数字飞光 16 numberArr.forEach((node,index)=>{ 17 blockFloat(node,numFloadDis); 18 }) 19 //市中心上方亮线的流动 20 float.eachChild(node => { 21 let offset = node.s('shape3d.uv.offset') || [0, 0]; 22 node.s('shape3d.uv.offset', [offset[0] + 0.05, offset[1]]); 23 }) 24 } 25 });
我们先讲前面四种较为简单动画的实现,像市中心上方亮线的流动动画逻辑简单,我们就直接写在了 action 函数中,每一步控制 x 方向上的贴图偏移即可
其它动画我们都封装为了对应的函数,如下
1 //道路偏移动画 2 //定义三种道路的步进 3 const dltRoadSmall = 0.007, dltRoadMedium = 0.009, dltRoadBig = 0.01; 4 //获取三种道路节点 5 let roadSmall = dm.getDataByTag('roadSmall'); 6 let roadMedium = dm.getDataByTag('roadMedium'); 7 let roadBig = dm.getDataByTag('roadBig'); 8 let float = dm.getDataByTag('float'); 9 //定义偏移动画函数 10 let uvFlow = function(obj,dlt){ 11 let offset = obj.s('all.uv.offset') || [0, 0]; 12 obj.s('all.uv.offset', [offset[0] + dlt, offset[1]]); 13 } 14 15 //水波缩放动画 16 //定义扩大范围和每步扩大速度 17 const maxScale = 1.5, dltScale = 0.06; 18 //获取缩放节点 19 let scaleList = dm.getDataByTag('scale'); 20 //定义缩放函数 21 let waveScale = function(obj, dlt, max, min){ 22 obj.eachChild(node => { 23 // 扩散半径增加 24 if (!node.a('max')) node.a('max', node.getScaleX() + max); 25 if (!node.s('shape3d.opacity')) node.s('shape3d.opacity',1); 26 let s = node.getScaleX() + dlt; 27 let y = node.getScale3d()[1] 28 let opa = node.s('shape3d.opacity') - 0.02; 29 // 扩散半径大于最大值的时候,重置为最小值,透明度设为1 30 if (s >= node.a('max')){ 31 opa = 1; 32 s = 0; 33 } 34 // 设置x,y,z方向的缩放值 35 node.s('shape3d.opacity',opa) 36 node.setScale3d(s, y, s); 37 }); 38 } 39 //旋转图元 40 //定义三种不同旋转图元数组和旋转速度 41 const roationFC = [], roationD = [], roationD2 = [], dltRoattionD = Math.PI / 90, dltRoattionD2 = Math.PI / 60, dltRoattion = Math.PI / 30; 42 //获取所有旋转图元并分别放入数组中 43 let roationFCDatas = dm.getDataByTag('roationFC'); 44 let roationdDatas = dm.getDataByTag('di'); 45 roationFCDatas.eachChild(node =>{ 46 node.eachChild(node => { 47 if (node.getDisplayName() === '风机叶片'){ 48 roationFC.push(node); 49 } 50 }) 51 }); 52 roationdDatas.eachChild(node => { 53 if (node.getDisplayName() === '底'){ 54 roationD.push(node) 55 } 56 if (node.getDisplayName() === '底2'){ 57 roationD2.push(node) 58 } 59 }); 60 //定义旋转函数 61 let rotationAction = function(obj,dlt){ 62 obj.forEach(node => { 63 if (node.getDisplayName() === '风机叶片'){ 64 //获得当前旋转角度 65 let rotationZ = node.getRotation3d()[2]; 66 //每步增加dlt 67 node.setRotation3d([0,0,rotationZ + dlt]); 68 } 69 if (node.getDisplayName() === '底' || node.getDisplayName() === '底2'){ 70 //获得当前旋转角度 71 let rotationY = node.getRotation3d()[1]; 72 //每步增加dlt 73 node.setRotation3d([0,rotationY + dlt,0]); 74 } 75 }) 76 }
写完之后我们再看一下动画效果
最后就是我们的稍微繁琐一点的数字飞光动画了。每座城市上方都有不同的六条飞光,我们需要每次都是随机出现两条,并且每条的速度都是不一样的。和之前的动画一样的,我们先获取所有的飞光节点并分类好,如下
1 //数字浮动 2 let numberArr, numFloadDis = 15, numFloatDlt = 0.07; 3 numberArr = new Array(28); 4 for (let i = 0;i < 28; i++){ 5 numberArr[i] = new Array(6) 6 } 7 //产生两个随机数,并以数组形式返回 8 let randerdom2 = function(){ 9 let num1 = Math.floor(Math.random() * 3); 10 let num2 = Math.floor((Math.random() * 3 + 3)); 11 return [num1,num2]; 12 } 13 //将所有的浮动数字按城市分组添加进数组 14 let i = 0,j=0; 15 dm.each(node => { 16 if (node.getDisplayName() === '飞光组'){ 17 node.eachChild(node => { 18 node.s('shape3d.opacity',0); 19 node.setElevation(0); 20 numberArr[i][j++] = node; 21 }) 22 j=0; 23 i++; 24 } 25 }); 26 //属性初始化 27 let initArrAtr = function(){ 28 for (let i = 0; i < numberArr.length; i++){ 29 for (let j = 0; j < numberArr[i].length; j++){ 30 //每条数字的随机数度 31 numberArr[i][j].a('randomSpeed', (numFloatDlt * 100 + Math.floor(Math.random() * 5))/100); 32 //控制每条数字是否停止上升 33 numberArr[i][j].a('stop',false); 34 //每栋楼上的已升起的飞光数量 35 numberArr[i].comNum = 0; 36 //每栋楼层当前的两条飞光 37 numberArr[i].one = randerdom2()[0]; 38 numberArr[i].two = randerdom2()[1]; 39 } 40 } 41 } 42 initArrAtr(); 43 //重置单楼属性 44 let czArr = function(singleRoom){ 45 //每栋楼上的已升起的数量 46 singleRoom.comNum = 0; 47 //重新随机设置每栋楼层出现的两条飞光 48 singleRoom.one = randerdom2()[0]; 49 singleRoom.two = randerdom2()[1]; 50 //设置飞光的随机速度 51 singleRoom.forEach((node, index)=>{ 52 node.a('stop',false); 53 node.a('randomSpeed', (numFloatDlt * 100 + Math.floor(Math.random() * 5))/100); 54 }) 55 }
当初始属性都设置完成后就该定义我们的动画函数了
1 let blockFloat = function(obj, dis){ 2 //获取当前建筑 3 let allNumArr = obj; 4 //获取当前建筑出现的两条飞光 5 let floatArr = [allNumArr[allNumArr.one],allNumArr[allNumArr.two]]; 6 let lth = floatArr.length; 7 //遍历并控制这两条飞光及动画 8 for (let j = 0; j < lth; j++){ 9 let node = floatArr[j]; 10 //如果当前飞光已停则停止此条飞光下一步动画 11 if (node.a('stop')) continue; 12 //获得当前飞光初始高度如果没有则手动设置当前为初始高度 13 let startE = node.a('startE'); 14 if (startE == null) node.a('startE', startE = node.getElevation()); 15 // 获得当前飞光速度和透明度值 16 let dlt = node.a('randomSpeed'); 17 let float = node.a('float') || 0; 18 let opa = node.s('shape3d.opacity') || 0, 19 opaDlt = 0.01; 20 21 node.setElevation(startE + dis * float); 22 //上升的高度到达一定值设置透明度为1 23 if (float > 8){ 24 node.s('shape3d.opacity',1) 25 opaDlt = -0.02 26 } 27 //上升的高度到达最高则让当前建筑飞光到达数量加一,并停止进一步上升 28 if (float > 12){ 29 allNumArr.comNum ++; 30 node.a('stop',true); 31 node.a('float', 0); 32 node.setElevation(startE); 33 node.s('shape3d.opacity',0); 34 //当前建筑飞光到达数量到达两条,重置建筑上所有飞光属性 35 if (allNumArr.comNum === 2){ 36 czArr(allNumArr); 37 } 38 continue; 39 } 40 float += dlt; 41 opa += opaDlt; 42 node.s('shape3d.opacity',opa) 43 node.a('float', float); 44 } 45 }
我们看下效果
到这,我们所有的动画就已经写完了。还等什么呢,一起来创建一个属于你自己心中理想的智能化城市吧
(ps: 不仅如此,HT官网中 还包含了数百个工业互联网 2D 3D 可视化应用案例,点击这里体验把玩:http://www.hightopo.com/demos/index.html)