Tumax-H5设计要点

Tumax-H5设计要点

H5 项目中数据与视图的同步响应

Vuex 中不能解决的问题

使用 Vuex 更贴合 Vue 项目,Vuex 支持响应 Vue 内部的响应事件,比如:当某状态改变时 Vue 会同时触发改变 computed,render 等。

但缺点是在其他库中缺无法观察响应 Vuex 中的变化,比如:当 Vuex 中的一个状态值改变,需要对应的触发响应到 ThreeJS 和 PIXI 中去改变摄像机的位置,则需要在某个在生命周期范围内的组件去处理响应事件,并通知到 ThreeJS 和 PIXI 中去改变响应数据。这样整个过程就变得十分麻烦。

另外,当 ThreeJS 中有任何数据改变,反过来如果要触发改变 Vue 的 Dom 和 PIXI 中的绘图,就又需要在 ThreeJS 中去触发改变双方的事件。这样的做法不符合 Flux 的设计思想,整个过程也变得十分累赘和繁琐。

底层实现的设计模式–观察者模式

最理想实现方式应该是:有一个公共的数据状态在管理整个数据层,比如相机的位置由他的向量 Vector3 决定,而其中包含的 x,y,z 这 3 个分量是他的数据层中最重要的信息,还有相机的旋转同样由 Euler 中的 x,y,z 决定。

那么在项目设计中,就需要做到当相机的 x,y,z 的任意一个值改变时能同时通知到 Vue、ThreeJS 与 PIXI。这样处理后,在编写代码时,就只需要编写订阅状态变化后各自层的实现内容。比如 ThreeJS 订阅到相机位置变化,就需要修改自身的相机配置;PIXI 订阅到相机变化需要转化成对应的俯视图重新绘制 2D 层的显示;Vue 订阅到相机变化需要做单位转换后呈现数据到 Dom 结构中进行数据双向绑定。

反过来,当任意一个层去触发改变时,出版者会重新发布新事件到所有订阅者中去触发新一轮的改变动作。这样 Vue 中一个 Slider 的双向绑定就可以同时触发改变到 3D 和 2D 层。

Mobx 解决的问题

虽然在我们的设计中,这种思路很完美,但如果要抽象出这样一个出版者,其中需要用到的内容细节却很多。比如 Vue,ThreeJS,PIXI 是在什么时机去订阅到出版者,如果在每个使用到出版者发布的变量位置都需要使用 addEventListener 添加,那么在使用起来其实并不轻松而且需要改很多地方。

在 Vue 组件中,也不能使用 Vue 内部已经实现的响应机制,而是需要在观察到变化后手动更新视图,那这样 Vue 内部实现的虚拟 Dom,计算变量缓存这些性能优化也都不会使用。同样的类似 v-model 这样的语法糖也同样会不能使用。这种情况肯定不是我们最终想要的结果。

对于这样类似的问题,最终决定引入 Mobx 来解决相关问题,首先 Mobx 有较为完善的文档和方法封装,在定义一个出版者变量时使用装饰器就变得非常轻松,另外 Mobx 内部有借鉴 Vue 的响应原理,在使用起来也十分相似。

这样以来,我们需要做一个可观察的 Vector3 就变得非常轻松!

ThreeJS 中的响应代码实现:

如何适配并应用到 Vue 项目的中

由于 Mobx 官方支持的是 React,所以在我们一般说 Mobx 还是属于 React 技术栈,那么在响应 Dom 这件事情上就需要我们做一些额外处理来达到 Vue 组件中可用 Mobx。在看过 Mobx 源码后了解到,其实 Mobx 内部实现响应机制很像 Vue,如果把 Mobx 变量看做一个 Vue 中的 computed,那么我们的使用过程就变得非常舒服了。

而这其中就需要开发一个 Vue 的兼容 Mobx 插件,在一个 Vue 组件 create 创建时将定义的 FromMobx 中的计算方法转换成为 computed 的加入到 Vue 组件的 computed 中,这样就可以将 Mobx 简单的接入到 Vue 项目中。

这样做以后的收益

在项目中全面接入 Mobx 管理状态后,在很多地方实现相关功能都变得非常轻松,在相机的位置改变,旋转时都只需要改变对应的数据层,即可完成整个操作,实现思路变得非常清晰,各自层级处理的事件互不干扰。

H5 项目的添加模型(Event 事件处理机制)

为什么加入 Event 事件处理(痛点是什么)

在项目处理新增模型的实践时,需要对监听 mouse 的 start,move,end 等事件,随着项目的不断扩展和代码量的增加,渐渐发现有非常多的交互需要使用到鼠标的的相关事件,我们在开发过程中进场会遇到在调试过程中跑了别人写的 mouse 事件,甚至还会遇到在别人的代码中直接 event.preventDefault()这类停止冒泡的命令。

还有,在考虑到移动端的适配 Touch 的 start,move,end 也同样需要做事件处理,这就使得我们的项目中对于 Canvas 的监听事件非常混乱。编写 ThreeJS 交互相关代码时非常难受,因为编写每一行代码都可能出现不运行,或者和别的交互事件同时运行。比如:当鼠标在 3D 场景中拖动时,要的效果是让场景中的物体移动,此时环绕控制器(控制相机在场景中围绕某个中心点旋转)也会触发。

所以在处理 3D 场景的交互时,需要抽出一个模块出来,用于专门管理场景中会出现的一些交互问题。也就是说需要抽出一个 Manager 解耦各类 Event,比如旋转,移动,抬高,添加材质,键盘事件,组合等等。

是如何实现的

DOMEventManager 的封装

首先封装了 DOMEventManager 来监听所有页面中能用到的交互事件,包含了窗口 resize,在 ThreeJS 画布中的 mouse、touch 事件,在 Window 中的 mouse、touch 事件(主要用于处理一些在 Canvas 之外的处理事件,比如从左侧的 sideBar 中拖动模型进入场景)。

兼容了 mouse 和 touch 事件的主要数据结构,达到使用同样的冒泡事件处理 touch 和 mouse 事件来快速完成对交互的适配工作。

另外引入 Hammer,来快速兼容一些特殊的交互事件(pan\pinch),比如当用户在场景中在模型的位置按下鼠标,如果接下来他做了横向平移,那么接下来的事件会交给摄像机控制器去处理摄像机的移动;如果接下来他在 200 毫秒以内抬起鼠标,并且鼠标的位置在之前按下鼠标的一定范围内,就认为他在点击模型,那么接下来的事件就会交给模型控制器去处理。

Event 事件监听处理

首先在 3D 中建立 Events,这其中编写用于处理不同事件的具体实现方法,比如添加材质,组合\解组,调高模型,移动模型,旋转模型,切换模型状态等等。

在 Base 中封装了对整个事件的总体开关,当开关开启时,相关事件开始监听并处理各自事件。反之,当该事件开关关闭时,remove 监听事件来避免事件紊乱。

使用 Mobx 状态监听,控制 Event 开关

在完成了对 Dom 的事件兼容封装和 Event 事件管理封装后,就需要编写各自事件所处理的具体内容,以及定义在什么情况下该 Event 开关会开启,又在什么时候进行关闭。拿模型旋转来举例,只需要借助 Mobx 的方法监听对应状态,即可完成对整个事件的监听操作。而当开关关闭时,dispose 函数会被触发来关闭整个过程中的监听事件!

H5 项目的模型控制器

模型控制器需要处理的内容

在 3D 场景中需要有一个控制器的东西来操作已经在场景中的物体模型,虽然只是对物体进行简单的移动、旋转、调高操作,但其中涉及到的细节非常多。

比如在模型控制器距离摄像机的位置较远时,如果不对模型控制器进行处理将会导致模型控制器过小而无法操作。

比如在操作过程中,任何操作都可能会导致鼠标的射线不是与物体直接相交的,比如正在调旋转的过程中,一开始鼠标是和旋转箭头想模型相交的,随着鼠标移动,按照用户习惯鼠标将不再与旋转箭头相交,而是与整个地面相交。

比如在调整高度之后,如何在物体移动或旋转过程中,相交地面还是之前的高度,则会导致物体重新被调至地面高度位置,也就是 0。

比如在任何 3D 交互操作中,如果直接按照相交点来设定实际参数将会出现瞬移情况。比如一个物体较大时,中心点位置在物体的底部中央,在点击物体的左上角位置开始移动时,物体相交点将会是左上角点到地面的射线位置,那么中心点将会在一开始就直接移动到相交点的位置。

比如在摄像机控制器移动时,调高箭头需要一直面向摄像机 在调高箭头 所在的平面内 的投影方向。

在处理这么多细节的问题过程中,如果不做好解耦工作,那么整合模型控制器的代码量将会非常庞大,也非常难以阅读并且 Bug 数量一定不会少,毕竟你永远不知道你的用户是如何使用你开发的产品。

采用 Mobx 处理公共状态

为了解耦不同的情况下需要处理的事件,我们在全局封装了 ModelActiveData 的单例用于管理整个场景中正在操作的模型,包含了正在编辑的模型,拖动的模型,旋转的模型,调高的模型,移动的材质,以及摄像头是否在移动等状态。

有了这些可观察的 Mobx 状态,就可以在项目的任意位置来根据他们的不同事件作出响应,比如在摄像机控制器中,就可以监听如果有任意的 editingData 中的数据不为空,就停止摄像机控制器的所有操作。

比如在监听到在 data 中的 Editing 不为空时就显示模型控制器以及左侧的属性面板。并且显示的数据就是在 editing 中设置的模型数据。

采用事件处理机制处理不同点击后的事件委派

完成了上面的 Mobx 状态封装,在 Events 中就可以监听 Editing 的数据变化,当有物体模型在进入编辑状态时,需要对鼠标在 Canvas 中的 mousedown、touchstart 等事件做出监听,通过不同的点击事件,判断进行不同的事件委派。

旋转实现及细节处理

在收到事件委派后相关的具体业务事件方法将会处理对应的相关逻辑了,这里拿相对简单的物体旋转来具体分析一下。

首先在开始触发旋转时为了防止出现像前面说的那种瞬移的问题,必须要拿到起始点的相交点位置,和起始时的旋转角。下面有一张草图来理解旋转的选择位置和实际位置。

此处的位置都是相对位置坐标,不是绝对位置坐标,坐标原点都是模型中心点,也就是说坐标原点就是旋转圆环的圆形点。首选需要计算出点击的位置的相对位置点,然后计算结果时也计算中相对位置点,计算两向量之间的顺时针偏移角,和起始旋转角相加。此时可能会因为相加得到大于 2π 的弧度,再将弧度归一化。最后,因为一个圆环是有 8 个刻度线,所以旋转角再做一个吸附转换,即可得到最终需要的旋转角。

H5 项目的组合与解组

Model3D/Group3D 的封装与继承

由于模型和组合在抽象的看待他们是相同的,对模型组合的操作同样可以理解为对单个模型的操作,他同样有自身的 Position、Rotation、Width、Height、Length 这些数据。

那么在编写 Group3D 时,就考虑了将 Group3D 和 Model3D 共同继承与 ModelBase。同样的在数据层,也封装了对应的 Model 和 GroupModel 来处理数据的改变。前面说过我们的所有 3D、2D 层的具体改变都来自于数据层,所以在 Model 和 GroupModel 中都定义了可观察的成员变量。

从模型层计算边框大小

按照常规方式,生成一个 Mesh 的 BoundingBox 需要在 Mesh 完全载入后找到该 Mesh 的 Geometry 后遍历其所有的 Vectors 然后找到 8 个最大、最小的 Vector 点来确定然后生成。

这样的方式是传统的实现方式,其中有三个缺点:

  • 生成边框必须要等 Mesh 完全 loaded 模型数据。在模型组合时,如果该模型还未完全载入时,则无法计算出正确的 BoundingBox。
  • 计算量较大,一个普通的模型一般会有将近 1w 个三角面组成,一个三角面有 3 个 Vector 点。所以当计算一个新模型的边框,或者更新一个组合的新边框时将会遍历计算n(模型数量)*faces(面数)*3次。
  • 完全依赖于 ThreeJS,如果从 2D 层操作并计算其外层边框时,在 2D 逻辑中又需要 2D 的方式实现一遍。

针对分析这 3 点问题后,决定从 Model 数据层出发,自己开发一套生成边框数值的方法。

因为在提供的数据接口中已经存在对应模型的长宽高等数据,通过计算,是可以明确得到对应 8 个点的相对坐标的。其中存在的问题便是如何从纯数据的方式将相对坐标转化成世界坐标。

可能你会觉得很简单,模型不是在数据层也有他的 position 吗,一个向量加减不就搞定了吗?但是如果加入旋转呢,比如该物体在 Y 轴方向旋转了 π/4,那么他的位置就会变成以下情况。那如果在加入 X 轴方向和 Z 轴方向的旋转呢?

使用 Matrix4 进行模型层计算相对/绝对位置 和外层边框

在研究过 ThreeJS 的 Object3D 的源码后,使用 Matrix4(4x4 矩阵)完成了对物体顶点的相对坐标转换,主要是可以使用 Vector3 应用一个世界的 Matrix4 来完成对 Vector3 的计算,生成 8 个世界坐标顶点。

拿到 8 个世界坐标顶点后,可以再通过 ThreeJS 中提供的 Box 方式来快速扩展生成 Box 盒子,通过 ThreeJS 中的 Box 提供的方法,可以得到对应的中心点及 BoxSize 来生成对应 Group 的 BoundingBox 和 CenterPoint。

从数据层拿到 Box 实例后,再去 3D 层或者 2D 层建立 BoundingBox 就变得非常简单了!

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×