智能数据洞察 DataWind是支持千亿级别数据自助分析的一站式数据分析与协作平台。从数据接入、数据整合,到查询、分析,最终以数据门户、数字大屏、管理驾驶舱的可视化形态呈现给业务用户,让数据发挥价值。详情:https://www.volcengine.com/product/datawind
DataWind沉淀了丰富美观的行业Demo,包含分析型数据看板与酷炫动态大屏,为用户制作优质看板和大屏提供参考。其中数字孪生是特色之一,数字孪生指将物理世界中的物体、人、自然、环境、生产制造过程等要素一一复制,形成全量数字三维模型,结合科学的数学模型与智能算法,分析和仿真微观世界和宏观世界的变化过程,解决城市管理或者工业制造过程中资源分配不合理带来的各种问题。
接下来豆皮范儿给大家带来数字孪生中,进行3D地图事件系统重构,使用的xGis的图形库
背景
xGis 是一个基于数据驱动,简单、易用、完备的 web 3D 地图库
专注于地理空间数据可视化,拥有炫酷展示效果的同时兼顾高性能/高渲染质量,在内/外部多个项目落地使用
tips: xGis 可以灵活支持十余种自定义图层的增删改查,组成想要的复杂场景
问题
随着各种各样新图层的累加, 出现了如下问题
业务方:不同图层 交互字段配置 不统一
有些图层支持交互,有些不支持交互,且交互字段根据不同图层开发同学决定
业务方:图层暴露的内置事件逻辑 不符合场景需求
场景:双击触发地图钻取的交互能不能改成单击?
场景:可不可以蓝色柱子的hover样式,value大于10时为绿色,小于10时为红
色?
开发者:不同图层 关于交互模块 重复开发,开发成本较大
场景:新图层开发完毕,要支持交互的话还得改交互模块的逻辑(如下)
交互性能:由于各模块重复开发,导致 事件监听 里逻辑判断复杂冗余
/**
* 老的事件监听
*/
handleEvent = (e)=>{
// 遍历所有图层
layerManagers.layers.forEach((layer)=>{
const objects = []
// 支持交互绑定的
if (layer instanceof mapLayer){ // 不同图层需要casebycase处理
objects.push(mapLayer.meshGroup)
}else if (layer instanceof bubbleLayer){
objects.push(mapLayer.group)
}
// 其他如柱状图没有写逻辑,则不支持交互
const objIntersects = intersectObjects(objects,mouse)
// 检测成功
if (objIntersects[0]){
if (e.type === 'mousemove'){
layer.emit('hover',e) // 图层只要支持交互就emit,不管当前eventType是否有用户监听
} else (e.type === 'click'){
layer.emit('select',e)
}
}else {
if (e.type === 'mousemove'){
layer.emit('hover', null)
} else (e.type === 'click'){
layer.emit('select', null)
}
}
})
}
重构思路
事件分类
首先梳理一下三位场景有哪些事件触发主体(Selector),不同的事件主体的后续逻辑也不相同(Trigger / Effect),触发主体分类后统一处理
containter
containter 就是用户传入的绑定dom容器,主要场景是监听resize 后 auotFit
/**
* 劫持监听 containerDom resize
* ! MutationObserver 不行,因为config的attributes观察目标属性变化,是指css属性变化,width:100%,虽然宽度px是变了,但是这个属性没变
* ! 监听window.resize() 也不合理,逻辑覆盖面太小
*/
private __initContainerDomResizeHandle() {
const { autoSize, containerDom } = this.props;
if (autoSize) {
this.containerDomResizeObserver = new ResizeObserver(() => {
this.__containerDomResizeHandler();
});
this.containerDomResizeObserver.observe(containerDom);
}
}
也支持用户监听鼠标交互(绑定dom ),举个栗子🌰:
鼠标移出地图容器时 隐藏当前地图内的Popup
gis.on('mouseout', (ev) => {
popup.set({visible:false})
});
context
监听 webgl 上下文是否丢失,抛出报错做一些容错处理
canvasDom.addEventListener('webglcontextlost', this._contextLost, false); //上下文丢失事件
canvasDom.addEventListener( // 上下文恢复事件
'webglcontextrestored',
this._contextRestored,
false
);
components
即组件库,如 control(layer,zoom,scale),popup等。组件的本质就是独立dom,事件独立管理
控制图层显示隐藏
普通:
hover后可切换:
控制图层缩放:
显示当前比例尺:
controls
地图控制器提供了多种交互功能 - 鼠标交互, 触摸交互, 以及其他 手势交互功能。
window
监听window,有些库 对于resize的监听放到了这里
window.addEventListener('orientationchange', this.onWindowResize, false); // 设备的纵横方向改变时触发
layer
图层,最关键、最常用的事件监听,即 WebGL 渲染主画面
hover
即为悬停激活,默认逻辑为
图层内 hover 态只能拥有一个
移开时 hover 态取消
select
即为选中激活 逻辑为
图层内 select 态可以有多个
已 select 区域 再次 select 则为 取消 select
点击空白区域 取消全部 select 态
active 激活
支持批量
heatmapLayer.active({name:'四川省'}) // 行政区域图层激活四川省
bar.active({id:123672}) // 激活 柱子
/**
* 激活 参数
*/
export interface IActiveFnProps {
id?: Array<number> | number; // 激活的 object.id
name?: Array<string> | string; // 激活的 object name, 优先级低于id
color?: ColorType; // 激活颜色,默认为图层 interaction select color
cover?: boolean; // 是否 全量覆盖, 默认 false,则active时不会清空之前已active的对象
type?: 'hover' | 'select';
}
unActive 取消激活
默认 不穿参 则取消全部
/**
* 取消激活 参数
*/
export interface IUnActiveFnProps {
id?: Array<number> | number; // 取消激活的 object.id,不传则全部取消
name?: Array<string> | string; // 激活的 object name, 优先级低于id
type?: 'hover' | 'select';
}
部分图层独有事件
如 钻取 drillup drilldown drill 等
交互触发:
drill: {
preventMouse: false,
drillDownEvent: 'dblclick', // 双击 触发向下钻取
drillUpEvent: 'undblclick', // 双击非地图区域 触发向上钻取
}
API 触发,如下钻到陕西省:
baseMapLayer.drillDown('610000')
custom
xGis 封装的一些自定义事件,例如生命周期 / 控制器交互回调等
gis.on('loaded', () => {}); //地图加载完成触发
gis.on('destroy', () => {}); // 地图容启动销毁时触发
gis.on('resize', () => {}); // 地图容器大小改变事件
gis.on('viewportChange', () => {}); // 地图视角发生变化时触发,pan rotate pitch zoom 均会触发
gis.on('pan', () => {}); // 地图平移时触发事件
gis.on('panStart', () => {}); // 地图平移开始时触发
gis.on('panEnd', () => {}); // 地图移动结束后触发,包括平移,以及中心点变化的缩放。如地图有拖拽缓动效果,则在缓动结束后触发
gis.on('zoom', () => {}); // 地图缩放级别更改后触发
gis.on('zoomStart', () => {}); // 缩放开始时触发
gis.on('zoomEnd', () => {}); // 缩放停止时触发
gis.on('rotate', () => {}); // 地图水平旋转更改后触发
gis.on('rotateStart', () => {}); // 水平旋转开始时触发
gis.on('rotateEnd', () => {}); // 水平旋转停止时触发
gis.on('pitch', () => {}); // 地图上下倾斜更改后触发
gis.on('pitchStart', () => {}); // 上下倾斜开始时触发
gis.on('pitchEnd', () => {}); // 上下倾斜停止时触发
语法设计
先参考JavaScript 事件最核心的包括事件监听 (addListener)、事件触发 (emit)、事件删除 (removeListener)
举个栗子 🌰:
const button = document.querySelector('button');
button.addEventListener("click", (event) => {
// do sth else
})
我们向按钮单击事件添加了一个listener (监听器),并且已经订阅了一个正在被发出的事件,当事件发生时会触发回调。每次单击该按钮时,都会发出该事件,而该事件会触发回调。
当处理现有代码库时,或许需要触发自定义事件。不像单击按钮这样的特定DOM事件,而是假设想基于其他触发器发出一个事件,并得到一个事件响应。我们需要一个自定义事件派发器来实现这一点。
事件派发器是一种模式,它监听一个已命名的事件,触发回调,然后发出该事件并附带一个值。有时这被称为“发布/订阅”模型或监听器
eventemitter3 功能比较简单,就是一个事件注册触发的类库。注册发布,非DOM事件。
// 我们的目标语法设计
barLayer.on("click", (event) => {
// do sth else
})
图层交互事件代理
上面关于 layer 的交互事件监听是如何绑定上的呢?如柱状层,蜂窝热力层它们只是抽象的业务概念,并不是一个独立的dom呀。这里的思路是将图层的事件监听由上层 canvas dom代理。
我们的屏幕是二维的,但是我们展示物体的世界是三维的,当我们在构建一个物体的时候我们是以一个三维世界既是世界坐标来构建,而转化为屏幕坐标展示在我们眼前。那么在交互判断是否命中时,就得由 屏幕坐标 经过一系列坐标转换后判断
1. 图层基类提供 on 事件绑定方法 。判断不是自定义事件后 将触发 dom 绑定。
好处1:用户可以绑定任意交互事件,没有所谓的内置事件白名单,比如只支持 click
好处2:地图不会默认绑定任何UI事件,场景无hover监听时 滑动cpu不会升高
/**
* 绑定图层事件
* @param eventType
* @param handle
* @param context
*/
public on(
eventType: CustomUIEventType | LifeCycleEventType | string,
handle: (...args: any[]) => void, // 回调函数
context?: any
) {
// case1: 判断是否属于鼠标交互事件
if (!isNotCustomUIEventType(eventType)) {
this.eventManager.bindEvent(this.id, eventType as CustomUIEventType);
}
// case2: hover select LifeCycleEventType
this.ee.on(eventType, handle, context);
}
举个栗子 🌰
barLayer.on('click',()=>{ // 进入 case1 ,dom 增加监听
// to sth else
})
barLayer.on('destroy',()=>{ // 不会进入case1
// to sth else
})
2. UI 事件绑定 会去重后再由dom代理。维护对应 事件-图层ID 映射表
好处1: UI事件不会重复绑定
好处2: UI事件和图层ID映射起来,后续碰撞检测会只检测绑定该事件的图层
举个栗子 🌰
barLayer.on('click',(e)=>{
if (e){
}else {
}
})
// eventsPool {click: Set([barLayerID])}
mapLayer.on('unclick',cb)
// 不会重复往dom上绑定click事件
// eventsPool {click: Set([barLayerID,mapLayerID])}
/**
* 将 事件模型 插入 eventsPool,并触发ee.bindEvent
* 同类型 事件 有多个监听,但是 containerDom 对应只有一个 事件监听
* 比如 gis.baseMapLayer.on('mousemove', barPointLayer.on('mousemove',
* 但是 containerDom.addEventListener(mousemove一次
* @param originalUIEventType
* @param eventType
* @param layerID
*/
private __addEventToPool = (
originalUIEventType: OriginalUIEventType,
layerID: LayerId
) => {
// 将 对应事件 存在 原生事件下
let layerIDs = this.eventsPool.get(originalUIEventType);
if (!layerIDs) {
this.eventsPool.set(originalUIEventType, new Set());
this.ee.emit('bindLayerEvent', originalUIEventType);
}
layerIDs = this.eventsPool.get(originalUIEventType);
layerIDs.add(layerID);
};
3. 真正的dom事件绑定
handleEvent = (event: MouseEvent | TouchEvent) => {
const type = event.type as OriginalUIEventType;
// 1. 得到已绑定此事件的图层
const layerIDs = this.eventManager.eventsPool.get(type);
// 2. 遍历已绑定此事件的图层
layerIDs.forEach((layerID) => {
const layer = layerManager.get({ layerID }) as Layer;
if (layer) {
// 判断图层是否 匹配到了obj
const objIntersects = intersectObjects (layer,event.xy) // ! 根据不同 picking-engine 计算是否相交
const firstIntersect =
objIntersects.length > 0 ? objIntersects[0] : null;
// case1: 当前图层绑定此事件且匹配到了obj
if (firstIntersect) {
const body = {
x: offsetX,
y: offsetY,
code: 200,
properties: {
...firstIntersect.object.ext,
id: firstIntersect.object.id,
},
} as Partial<IEventEmitterArgs>;
// 通用 emit
layer.ee.emit(type, body);
layer.ee.emit('un' + type, null);
} else {
// case2: 当前图层虽绑定此事件,但是没有匹配到obj
// 通用 emit
layer.ee.emit(type, null);
layer.ee.emit('un'+ type, null);
}
})
}
picking-engine 技术实现
对于事件分类 layer(图层) 部分,由于载体不是独立 dom,而是 WebGLContext,所以交互事件检测(如点击了柱状图层的哪根柱子)要由自研的 picking-enigne 实现
CPU
第一种方案是通过 CPU 计算,判断交互事件是否在 layer 区域。
射线追踪法(raycasting) ,其基本原理是:从鼠标处发射一条射线,穿透场景的视椎体,通过计算,找出视锥体中哪些对象与射线相交。
首先,获取鼠标的屏幕坐标。其次,对其应用摄像机的投影和方向的矩阵变换,得到其在世界空间的坐标。然后,计算出一条射线,从视锥体的近端平面射向远端平面。再然后,对于场景中每一个对象的每一个三角,检查其是否与射线相交。假设你的场景中有1000个对象,每个对象有1000个三角,那么就需要检查一百万个三角。
对此,可以做一些优化,先检查对象的包围球或包围盒是否与射线相交,包围球或包围盒是指包含整个对象的球体或者立方体,如果射线未相交,就不需要检查组成该对象的三角们了。
什么是包围盒?
包围盒广泛地应用于碰撞检测,比如射击、点击、相撞等,每一个物体都有自己的包围盒。因为包围盒一般为规则物体,因此用它来代替物体本身进行计算,会比直接用物体本身更加高效和简单。
/**
* 射线检测
*/
raycast(raycaster, intersects) {
// step1: ray-sphere,Broad Phase (粗略检测)
if (geometry.boundingSphere === null) geometry.computeBoundingSphere();
if (raycaster.ray.intersectsSphere(_sphere) === false) return ;
// step2: ray-box,Broad Phase (粗略检测)
if (geometry.boundingBox !== null) {
if (_ray.intersectsBox(geometry.boundingBox) === false) return;
}
// step3: ray-triangle Narrow Phase (精细检测)
const intersection = ray.intersectTriangle()
if (intersection) {
intersects.push(intersection);
}
}
怎么样判断 射线是否和包围球相交?
/**
* 若这一射线与Sphere相交,则将返回true
* @param sphere 将被检查是否与之相交的Sphere
* @returns
*/
intersectsSphere(sphere) {
return (
this.distanceSqToPoint(sphere.center) <= sphere.radius * sphere.radius
);
}
射线与球体相交可能是射线几何相交测试的最简单形式,这就是为什么这么多射线追踪器显示球体图像的原因。由于其简单性,它还具有非常快的优势。
怎么样判断 射线是否和轴对齐 包围盒 ( AABB )相交?
怎么样判断 是否三角面相交?
首先判断射线是否与三角形所在的平面相交 (面的法向量与线的方向向量垂直 = 平行不相交)
如果相交,再判断交点是否在三角形内
根据右手定则,假设我们三角形的顶点连接顺序为v0,v1,v2则我们的三角形法线指向屏幕外,当我们v0v1和v0P的叉积同样指向屏幕外,即它们的叉积和法线的点积大于零,则表明P点在v0v1的左侧,当对3条边都执行此操作后都在左边,则可以表明P点在三角形内部,即射线和三角形相交。
总结
这种方式看起来效果不错,而且能处理很多用户场景,但是也问题:
基于CPU运算的 Javascript遍历每一个对象,检查其包围盒或包围球是否与射线相交,如果相交,它必须遍历组成该对象的每一个三角,检查它们是否与射线相交。CPU要做大量的工作,当你的对象由大量的三角组成时,这个过程会有些慢。
GPU
第一种方案是通过 GPU 计算,判断交互事件是否在 layer 区域。
为了完成GPU拾取,对每一个对象使用唯一的颜色进行离屏渲染。然后,检查鼠标位置关联的像素的颜色。这个颜色就能告诉我们哪个对象被选中。
每个对象会被绘制两次,一次用于观看,一次用于拾取。
🌰如下:
散点图层 在buffer frame 里 绘制成全局唯一的某个颜色(看起来都是蓝色,但是rgb不一样)
这样性能开销也是很大,但是拾取时我们只需读取1px,所以我们可以设置摄像机,只绘制1px,摄像机 只呈现一个大矩形的一个很小的部分。这应该能节省一些运行时间。
实现这种拾取方式,需要创建两个场景。一个使用正常的网格对象填充。另外一个使用“拾取材质”的网格对象填充。
创建 buffer frame代码如下:
/**
* 初始化pickingScene
*/
private __initGPUPick() {
// 1.为对象创建新场景和新渲染目标
this.pickingScene = new Scene();
// 方法1
this.pickingTexture = new WebGLRenderTarget(
containerDom.clientWidth,
containerDom.clientHeight
);
// 2. 为对象拾取创建新的着色器材质;
const pickingMaterial = new ShaderMaterial({
vertexShader: pickVshader,
fragmentShader: pickFshader,
transparent: false,
side: DoubleSide,
});
// 3.id-> object字典
this.pickingObjMap = new Map();
// 当有图层变化动态更新
this.props.layerManager.ee.on('loaded', (layer) => {
const geometry = mesh.geometry.clone();
// id 生成颜色策略,最大 999999
this.applyVertexColors(geometry, color.setHex(mesh.id));
const pickingObject = new Mesh(geometry, pickingMaterial);
this.pickingScene.add(pickingObject);
this.pickingObjMap.set(mesh.id, mesh);
})
}
拾取逻辑如下:
renderSystem.renderer.setRenderTarget(this.pickingTexture);
renderSystem.renderer.clear(); // 一定要清缓冲区,renderer没开启自动清空缓冲区
renderSystem.renderer.render(this.pickingScene, cameraSystem.camera);
// pixel是Uint8Array(4),分别保存rgba颜色的四个通道,每个通道取值范围是0~255
const pixelBuffer = new Uint8Array(4);
// 读取 坐标上的 宽度为1,高度为1的像素的颜色
renderSystem.renderer.readRenderTargetPixels(
this.pickingTexture,
this.mouse.x,
this.pickingTexture.height - this.mouse.y,
1,
1,
pixelBuffer
);
// 颜色转id
const id = (pixelBuffer[0] << 16) | (pixelBuffer[1] << 8) | pixelBuffer[2];
// id 匹配 mesh
const currentMesh = this.pickingObjMap.get(id);
CPU or GPU
那到底选择 CPU 还是 GPU 当作拾取引擎的计算策略呢?
🌰:当场景一直在变化时,如旋转 / 或新增图元,frame buffer 一直也在 同步更新矩阵运算 / 新增
cpu方案性能测试案例:
https://threejs.org/examples/?q=inter#webgl_interactive_cubes
gpu方案性能测试案例:
https://threejs.org/examples/?q=inter#webgl_interactive_cubes_gpu
可以看得出来 GPU 更适合数据量极大且稳定布局场景,不适合我们,我们是图层要动态增减 / 交互变换的,frame Buffer 会高频一直重绘,且检测时结束后切换回真实场景也有开销。 所以我们默认使用了CPU方案,当然也可以根据场景灵活切换
this.eventSystem = new EventSystem({
engine: PICKING_ENGINE.CPU, // 拾取引擎默认使用 PICKING_ENGINE.CPU, 可切换 PICKING_ENGINE.GPU
});
收益与反思
重构收益
针对之前提过的【二、问题】,都进行了优化
业务方:不同图层 interaction 交互字段配置统一
/**
* 图层基础交互配置
*/
export interface ILayerInteractionConfig {
hover?: Partial<{
enabled: boolean; // 是否开启 hover 交互,默认 true 开启,
effect: { // hover 交互响应
color?: ColorType;
poi?: boolean;
};
trigger: CustomUIEventType; // hover 交互触发事件
}>;
select?: Partial<{
enabled: boolean;
effect: {
color?: ColorType;
poi?: boolean;
};
trigger: CustomUIEventType;
}>;
}
业务方:图层事件可灵活绑定,配合回调可以支持任意场景
场景:双击钻取地图能不能改成单击呀?
方法1:
new mapLayer({
drill: {
preventMouse: false,
drillDownEvent: 'dblclick', // 双击 触发向下钻取 , 改为'click'
drillUpEvent: 'undblclick', // 双击非地图区域 触发向上钻取 'unclick'
}
})
方法2:
1.首先关闭默认钻取交互,preventMouse: true
2.自己绑定click监听,完成自定义下钻交互逻辑
mapLayer.on('click',(e)=>{
if (e){
barLayer.drilldown(e.ext.adcode)
} else {
barLayer.drillup()
}
})
场景:可不可以蓝色柱子的hover样式,value大于10时为绿色,小于10时为红色呀?
1.首先关闭默认hover逻辑
interaction: {
hover: {
enabled: false,
}
}
2. 自己绑定mousemove监听,完成自定义hover交互逻辑
barLayer.on('mousemove',(e)=>{
if (e){
if(e.ext.value>10){
barLayer.active(e.id,{color:'green'})
} else {
barLayer.active(e.id,{color:'red'})
}
}else {
barLayer.unactive()
}
})
开发者:图层 交互模块 统一基类实现,特殊图层可逻辑覆盖
场景:柱状图我开发完了,交互绑定先不加了,目前业务方也不需要
class BarLayer{
ctor(){
...
super.registerInteraction(); // 柱状层一行代码即可
}
}
class mapLayer{
ctor(){
...
super.registerInteraction(this.districtMeshGroup); // 地图层只有省份区块响应交互,国界线、省线都不需要响应交互,所以使用默认的 coreGroup 作为检测集合不合理
}
}
交互性能:新的事件系统去除截流后,性能依然提升20%
举个例子🌰:地图层存在10个图层,其中8个支持事件监听,其中5个绑定了事件监听,其中2个监听了A 类型事件。
当 A 类型事件触发时:
旧版思路:
1. 遍历全部图层=>支持交互的 // 10ms ,得到图层数量8
2. 每个图层 ,判断 图层内是否相交 // 8*100s
3. 相交的话,layer.emit(A, obj) // 8*1ms
(818ms,没有关心事件类型,也没有关心图层是否绑定了事件)
最优思路:
1. 遍历全部图层=>绑定 A 类型事件的 // 10ms ,得到图层数量2
2. 两个图层进行遍历,判断 图层内是否相交 // 2*100ms
3. 相交的话,layer.emit(A, obj) // 2*1ms
(212ms)
后续优化空间
case1: CPU场景下可以可以八叉树优化吗?
我们将视棱台划分成8个区域,分别从区域1到区域8,所有场景中的模型geometry都分布在这8个区域中,现在我们就通过这8个区域缩小射线碰撞的遍历geometry模型的范围。具体的操作很简单,那就是先让射线和这8个区域的棱台几何体进行射线相交计算,只有与射线产生交点的棱台几何体区域才是射线检测的模型空间范围,其余和射线不产生交点的区域中的geometry模型就不必参与到raycaster检测中来,这样就极大的缩小了遍历geometry的数量,从而优化了raycaster的功能。
我们来看看上图中依照8叉树优化逻辑进行的raycaster步骤。首先,射线只交2个区域的棱台他们分别是区域7和区域3,那么区域1,2,4,5,6,8中的所有geometry就都不用参与raycaster射线碰撞检测了,一下子我们就排除了Triangle3三角形3,因为他处于区域4中,不在检测区域范围内,是不是就减少了后面线段和面相交的计算量,优化了raycaster整体的性能。
直接缩小了检测范围,而且还能继续递归细分下去,比如区域3还能细分成8个小区域,将检测范围缩得更小,进一步排除检测区域外的多余模型,进一步减少计算量
但八叉树的成本增加了几何图形的内存消耗,当然还有八叉树的生成/更新,如果几何图形被修改,则必须重复操作。所以八叉树适合稳定布局场景,后续尝试一下是否正优化。
case2: 物体合并后渲染怎么解决?
维护三角面映射到原始几何图形的索引
维护两组几何体(一组merge 一组没merge)
以始为终,设计优先
应该以始为终,想清楚最终目标后再开始实现
此次事件系统设计,主要对比参考了 mapbox / antv L7 / echarts 的实现
正常代码逻辑复杂度会随着场景的复杂度而同步提升,但是如果存粹靠场景推动去升级迭代,会发现代码经常要重构 所以设计的拓展能力/前沿性得靠经验去未雨绸缪,经验不足就参考成熟产品的设计思路,等于快速拓展了场景复杂度,是一条捷径。
参考资料
游戏开发中的渲染加速算法总结:https://zhuanlan.zhihu.com/p/3230089
The End