// 创建二维拓扑视图 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);
总结