“以终为始,设计优先”:看 DataWind 数字孪生 3D 地图事件系统设计

8,618 阅读15分钟

作者:梁煜其

智能数据洞察 DataWind 是支持千亿级别数据自助分析的一站式数据分析与协作平台。从数据接入、数据整合,到查询、分析,最终以数据门户、数字大屏、管理驾驶舱的可视化形态呈现给业务用户,让数据发挥价值。详情请看:www.volcengine.com/product/dat…

DataWind 沉淀了丰富美观的行业 Demo,包含分析型数据看板与酷炫动态大屏,为用户制作优质看板和大屏提供参考。其中数字孪生是特色之一,数字孪生指将物理世界中的物体、人、自然、环境、生产制造过程等要素一一复制,形成全量数字三维模型,结合科学的数学模型与智能算法,分析和仿真微观世界和宏观世界的变化过程,解决城市管理或者工业制造过程中资源分配不合理带来的各种问题。

接下来给大家带来数字孪生中,进行三维场景事件系统重构所使用的 xGis 图形库。

一、背景

xGis 是一个基于数据驱动,简单、易用、完备的 web 3D 地图库。XGis 专注于地理空间数据可视化,拥有炫酷展示效果的同时兼顾高性能/高渲染质量,在内/外部多个项目落地使用。提供一个小 Tips: xGis 可以灵活支持十余种自定义图层的增删改查,组成想要的复杂场景。

二、问题

随着各种各样新图层的累加,出现了如下问题:

  1. 业务方:不同图层交互字段配置不统一
    有些图层支持交互,有些不支持交互,且交互字段根据不同图层开发同学决定。

  2. 业务方:图层暴露的内置事件逻辑不符合场景需求
    场景:双击触发地图钻取的交互能不能改成单击?
    场景:可不可以让蓝色柱子的 hover 样式,当 value 大于 10 时为绿色,小于 10 时为红色?

  3. 开发者:不同图层关于交互模块重复开发,开发成本较大
    场景:新图层开发完毕,要支持交互的话还得改交互模块的逻辑。

  4. 交互性能:由于各模块重复开发,导致事件监听里逻辑判断复杂冗余

/**
* 老的事件监听
*/
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)
      }
    }
    })
}

三、重构思路

3.1 事件分类

首先梳理一下三维场景有哪些事件触发主体(Selector),不同的事件主体的后续逻辑也不相同(Trigger / Effect)。我们这里根据事件触发主体作为分类,每个主体下事件触发类型又由鼠标交互、生命周期两大类组成。

3.1.1 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})
});

3.1.2 context

监听 WebGL 上下文是否丢失,抛出报错做一些容错处理:

 canvasDom.addEventListener('webglcontextlost', this._contextLost, false); //上下文丢失事件
 canvasDom.addEventListener( // 上下文恢复事件
      'webglcontextrestored',
      this._contextRestored,
      false
    );

3.1.3 components

监听第三方组件,即地图辅助组件,本质就是独立 DOM,特性为 HUD(始终正对于屏幕,不影响交互,类似锚点),主要事件为 DOM 的鼠标交互。

  • layerControl:控制图层显隐
  • zoomControl:控制图层缩放
  • scaleControl:显示当前地图比例尺
  • popup:标注信息窗口,用于展示地图要素的属性信息

3.1.4 controls

监听地图控制器,即实现地图的平移、缩放、旋转、倾斜等,主要事件为 DOM 的鼠标交互及生命周期,其中 DOM 的鼠标交互部分底层我们参考飞行器姿态,使用 Pointer Events API 实现,可以更好的适配鼠标(Mouse)、触摸(Touch)和触控笔(Pen)场景,这里不再展开。

3.1.5 window

监听 window,有些库对于 resize 的监听放到了这里:

window.addEventListener('orientationchange', this.onWindowResize, false); // 设备的纵横方向改变时触发

3.1.6 layer

监听图层,即创建的点、线、面、氛围等图层,我们的 WebGL 渲染主画面。主要事件为图层的鼠标交互及生命周期。技术原理见章节 3.3 模版。

常规的鼠标交互,如 clickdblclick 等均要支持,其次也我们进行了一层状态机抽象,方便业务更简便的接入使用

hover,即为悬停激活,默认逻辑为:

  1. 图层内 hover 态只能拥有一个
  2. 移开时 hover 态取消

select,即为选中激活,逻辑为:

  1. 图层内 select 态可以有多个
  2. 已 select 的区域再次 select 则为取消 select
  3. 点击空白区域取消全部 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';
}

部分图层独有事件,如baseMapLayer的钻取事件,drillupdrilldown 等。

交互触发:

drill: {
      preventMouse: false,
      drillDownEvent: 'dblclick', // 双击 触发向下钻取
      drillUpEvent: 'undblclick', // 双击非地图区域 触发向上钻取
}

API 触发,如下钻到陕西省:

baseMapLayer.drillDown('610000')

3.1.7 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', () => {}); // 上下倾斜停止时触发

3.2 语法设计

梳理完全部的事件后,需要设计一套统一易理解的 API。先参考 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
})

3.3 图层交互事件代理

上面关于 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
})
  1. 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);
  };
  1. 真正的 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 实现。

4.1 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)相交?

怎么样判断是否三角面相交?

  1. 首先判断射线是否与三角形所在的平面相交(面的法向量与线的方向向量垂直 = 平行不相交);
  2. 如果相交,再判断交点是否在三角形内。

根据右手定则,假设我们三角形的顶点连接顺序为 v0,v1,v2 则我们的三角形法线指向屏幕外,当我们 v0v1 和 v0P 的叉积同样指向屏幕外,即它们的叉积和法线的点积大于零,则表明 P 点在 v0v1 的左侧,当对 3 条边都执行此操作后都在左边,则可以表明 P 点在三角形内部,即射线和三角形相交。

总结

这种方式看起来效果不错,而且能处理很多用户场景,但是也存在问题:

基于 CPU 运算的 Javascript 遍历每一个对象,检查其包围盒或包围球是否与射线相交,如果相交,它必须遍历组成该对象的每一个三角,检查它们是否与射线相交。
CPU 要做大量的工作,当你的对象由大量的三角组成时,这个过程会有些慢。

4.2 GPU

第一种方案是通过 GPU 计算,判断交互事件是否在 layer 区域。

为了完成 GPU 拾取,对每一个对象使用唯一的颜色进行离屏渲染。然后,检查鼠标位置关联的像素的颜色。这个颜色就能告诉我们哪个对象被选中。

每个对象会被绘制两次,一次用于观看,一次用于拾取。

🌰 如下:

我们要判断图层点选中了哪个绿点(左下),我们其实在 buffer frame 里“fork”了一份新图层(右下),每个点都是全局唯一的某个颜色,这样就可以根据选中颜色来做匹配了。

这样性能开销也是很大,但是拾取时我们只需读取 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);

4.3 CPU or GPU

那到底选择 CPU 还是 GPU 当作拾取引擎的计算策略呢?

例如 🌰:当场景一直在变化时,如旋转 / 或新增图元,frame buffer 一直也在同步更新矩阵运算 / 新增。

CPU 方案性能测试案例:

threejs.org/examples/?q…

GPU 方案性能测试案例:

threejs.org/examples/?q…

可以看得出来 GPU 更适合数据量极大且稳定布局场景,不适合我们,我们是图层要动态增减 / 交互变换的,frame Buffer 会高频一直重绘,且检测时结束后切换回真实场景也有开销。 所以我们默认使用了 CPU 方案,当然也可以根据场景灵活切换。

this.eventSystem = new EventSystem({
      engine: PICKING_ENGINE.CPU, // 拾取引擎默认使用 PICKING_ENGINE.CPU, 可切换 PICKING_ENGINE.GPU
});

五、收益与反思

5.1 重构收益

针对之前提过的【二、问题】,都进行了优化

  1. 业务方:不同图层 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. 业务方:图层事件可灵活绑定,配合回调可以支持任意场景。

场景:双击钻取地图能不能改成单击呀?

// 方法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()
    }
})
  1. 开发者:图层交互模块统一基类实现,特殊图层可逻辑覆盖。

场景:柱状图我开发完了,交互绑定先不加了,目前业务方也不需要。

class BarLayer{
   ctor(){
   ...
   super.registerInteraction();  // 柱状层一行代码即可
   }
}


class mapLayer{
   ctor(){
   ...
   super.registerInteraction(this.districtMeshGroup);  // 地图层只有省份区块响应交互,国界线、省线都不需要响应交互,所以使用默认的 coreGroup 作为检测集合不合理
   }
}
  1. 交互性能:新的事件系统去除截流后,性能依然提升 20%。

举个例子 🌰:地图层存在 10 个图层,其中 8 个支持事件监听,其中 5 个绑定了事件监听,其中 2 个监听了 A 类型事件。

当 A 类型事件触发时,旧版思路和最优思路的对比如下:

5.2 后续优化空间

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: 物体合并后渲染怎么解决?

  1. 维护三角面映射到原始几何图形的索引;
  2. 维护两组几何体(一组 merge 一组没 merge),这种解决方案比较麻烦,暂不采纳。

5.3 以终为始,设计优先

应该以终为始,想清楚最终目标后再开始实现。

此次事件系统设计,主要对比参考了 mapbox / antv L7 / echarts 的实现。

正常代码逻辑复杂度会随着场景的复杂度而同步提升,但是如果纯粹靠场景推动去升级迭代,会发现代码经常要重构所以设计的拓展能力/前沿性得靠经验去未雨绸缪,经验不足就参考成熟产品的设计思路,等于快速拓展了场景复杂度,是一条捷径。

参考

游戏开发中的渲染加速算法总结:zhuanlan.zhihu.com/p/32300891


尾部关注.gif

扫码关注公众号 👆 追更不迷路