Web编程技术营地
研究、演示、创新

ViewportManager

撰写时间:2023-09-13

修订时间:2026-04-26

ViewportManager的职责在于统一管理同一视口布局中的各个视口。这是应用框架业务逻辑最复杂的部分。因其类属性较多,因此我们在讲到相应的功能时才列出相应的类属性。

构造方法

export class ViewportManager { static #instance = null; static get instance() { if (!ViewportManager.#instance) { ViewportManager.#instance = new ViewportManager(); } return ViewportManager.#instance; } predefinedViewports = { }; constructor() { for (let layoutName in VIEWPORT_LAYOUT) { this.predefinedViewports[layoutName] = null; } } ... }

还是单例模式。构造方法中先初始化类属性predefinedViewports的属性名称。该属性用于实现视口布局缓冲机制。

常量VIEWPORT_LAYOUT定义了应用框架所支持的所有视口布局。

export const VIEWPORT_LAYOUT = { Single: 0, // 1 viewport Left_Right: 1, // 2 viewports Left_2_Right: 2, // 3 viewports Left_3_Right: 3, // 4 viewports Left_Right_2: 4, // 3 viewports Left_Right_3: 5, // 4 viewports Left_2_Right_2: 6, // 4 viewports Top_Bottom: 7, // 2 viewports Top_2_Bottom: 8, // 3 viewports Top_3_Bottom: 9, // 4 viewports Top_Bottom_2: 10, // 3 viewports Top_Bottom_3: 11 // 4 viewports };

在构造方法的代码中,

for (let layoutName in VIEWPORT_LAYOUT) { this.predefinedViewports[layoutName] = null; }

for...in语句迭代常量的属性名称,类型为字符串。因此,完成初始化后,我们可以使用

this.predefinedViewports.Left_2_Right_2

来访问预定义的Left_2_Right_2视口布局。

useLayout方法

在程序开始运行时,客户端最早这样调用ViewportManager的方法:

app.doInInitViewports((cameraManager, viewportManager) => { ... viewportManager.useLayout(VIEWPORT_LAYOUT.Left_2_Right_2); });

useLayout方法让客户端使用指定的视口布局。

作为应用程序框架,我们极力简化了客户端的调用。客户端调用时,虽只有一行代码,但其内部涉及较多业务逻辑。依其关键职责,下面依序列出相应代码。

取出预定义的一组相机视图

根据视口布局,通过CameraManager来取出预定义好的一组相机视图。代码:

camManager.FrontViewCamera

是一个getter方法。为方便理解,这里列出其相应的代码如下:

get FrontViewCamera() { let camera = this.predefinedCameras.FrontViewCamera; if (!camera) { camera = new Camera(this.fov, [0, 0, this.eyeDist], CAMERA_TYPE.FRONT_VIEW); } return camera; }

同样,CameraManager类也使用了缓存机制。如果仓库中还没有这一类的相机,则实例化一个Camera类,最后返回该相机的实例。

因此上面代码的作用是,根据用户所选择的视口数量,取出一组预定义的相机;

视口数量预定义相机相机视图
1UserViewCamera用户视图
2UserViewCamera用户视图
FrontViewCamera前视图
3UserViewCamera用户视图
FrontViewCamera前视图
LeftViewCamera左视图
4UserViewCamera用户视图
FrontViewCamera前视图
LeftViewCamera左视图
TopViewCamera顶视图

前面WebGLApp一章中提到,在程序运行后,用户可以通过键盘快捷键在所激活的视口中切换相机视图。

重置所有视口

useLayout被设计为可以重复使用的方法。因此,无论何时调用该方法,需重置所有的视口,由resetViewports方法来负责此项工作。其代码如下:

viewports = []; // class property resetViewports() { this.viewports.splice(0); let viewportCSSDOMs = document.querySelectorAll('.viewport-border'); for (let dom of viewportCSSDOMs) { dom.remove(); } }

viewports是类的一个属性,类型为数组,用于存储特定视口布局下的所有视口。resetViewports方法要做两项工作。代码:

this.viewports.splice(0);

用于高效地清空整个viewports数组。而代码:

let viewportCSSDOMs = document.querySelectorAll('.viewport-border'); for (let dom of viewportCSSDOMs) { dom.remove(); }

则将网页中用以表示视口边框的所有DOMs全部删除,后面根据新布局再重新添加进来。

从缓冲池取出视口

predefinedViewports是一个以字典形式来实现的缓冲池对象。例如,下面代码可在左右布局的缓冲池中同时存储两个Viewport的实例:

而上面的代码是仅当客户端使用特定视口布局且缓冲池中无对应的缓冲对象时才逐个添加进相应的缓冲池中。

如果缓冲池中已有缓冲对象,则取出这些预定义好的视口,在网页中为它们添加相应的边框DOM,同时将视口也添加进viewports中。

设置当前激活视口

在这最后一步中,将viewports数组中的第一个视口激活为当前视口。

ViewportManager声明了以下方法:

#activeViewport = null; get activeViewport() { return this.#activeViewport; } set activeViewport(viewport) { this.activeViewport?.cssBorderDOM.removeAttribute('selected'); viewport.cssBorderDOM.toggleAttribute('selected'); this.#activeViewport = viewport; }

我们为activeViewport设置了gettersetter方法。getter方法只是简单地返回该属性。而setter方法则需额外的步骤,它需要将HTML网页中之前激活的视口的DOMselect属性取消掉,并为当前激活的视口设置select属性值。这样,界面上的边框就能自动地跟踪并显示客户所选择激活的各个视口。

代码:

this.activeViewport?.cssBorderDOM.removeAttribute('selected');

是一个简练的写法,如果activeViewport为空,则上面其后的代码不会被执行。否则,执行整行代码。

useLayout方法显得有点长,因为我们同时实现了缓存机制及同步DOM边框的功能。

客户端调用此方法后,内部状态已准备就绪。前面说过,为让应用框架跑起来,apprun方法中手动触发了一个resize事件,Controls类捕获到后,将调用ViewportManagerdoOnResize来处理。

处理事件

Canvas尺寸变化事件

ViewportManager对此事件的处理代码如下:

doOnResize() { for (let viewport of this.viewports) { viewport.doOnResize(); } }

具体委派每个viewport来完成此任务。详见上面Viewport一节。

相机视图变化事件

ViewportManager对此事件的处理代码如下:

doOnCameraViewChanged() { for (let viewport of this.viewports) { viewport.doOnCameraViewChanged(); } }

也是委派每个viewport来完成此任务。详见上面Viewport一节。

切换特定视口数量的视口布局

当用户重复按下快捷键1234时,ViewportManager需负责切换特定视口数量的所有视口布局。

layoutsOfParts = { 2: { currIndex: 0, layouts: [ VIEWPORT_LAYOUT.Left_Right, VIEWPORT_LAYOUT.Top_Bottom ] }, 3: { currIndex: 0, layouts: [ VIEWPORT_LAYOUT.Left_2_Right, VIEWPORT_LAYOUT.Left_Right_2, VIEWPORT_LAYOUT.Top_2_Bottom, VIEWPORT_LAYOUT.Top_Bottom_2 ] }, 4: { currIndex: 0, layouts: [ VIEWPORT_LAYOUT.Left_2_Right_2, VIEWPORT_LAYOUT.Left_3_Right, VIEWPORT_LAYOUT.Left_Right_3, VIEWPORT_LAYOUT.Top_3_Bottom, VIEWPORT_LAYOUT.Top_Bottom_3 ] } }; cycleLayout(viewportsNum) { let layoutObj = this.layoutsOfParts[viewportsNum]; let index = layoutObj.currIndex; let layout = layoutObj.layouts[index]; this.useLayout(layout); layoutObj.currIndex++; if (layoutObj.currIndex === layoutObj.layouts.length) { layoutObj.currIndex = 0; } }

代码比较简单,取出相同视口数量下的所有视口布局,使用currIndex作为指针取出当前视口布局,再调用useLayout方法即可。

视口的最大化与恢复

实现视口的最大化与恢复,其难点在于需在多种场合下精准跟踪并设置各个状态,并在多个状态之间相互转换时进行仔细的调试与验证。

从界面上来看,当我们将当前激活的视口最大化时,需要考虑以下状态:

  1. 将视口布局切换为单一视口布局
  2. 将当前激活视口的相机设置为单一视口布局的相机
  3. 设置一个视口是否最大化的变量

同时,我们还需要为从最大化视口恢复为正常视口做好准备。

  1. 如果仓库中已有单一视口布局,保存单一视口布局的相机
  2. 保存各个视口
  3. 保存原来的激活视口
  4. 保存原来的视口布局

当恢复视图时,需做:

  1. 如果原来单一视口布局有备份的相机,单一视口布局恢复此相机
  2. 恢复原来的所有视口
  3. 恢复原来的激活视口
  4. 恢复原来的视口布局
  5. 清空相应的状态变量

相应代码为:

isViewportMaximized = false; viewprotsToRestore = []; prevActiveViewport = null; prevCamInSingleLayout = null; prevViewportLayout = null; maximizeViewport() { if (this.predefinedViewports.Single) { this.prevCamInSingleLayout = this.predefinedViewports.Single[0].camera; } this.viewports.forEach((viewport) => { this.viewprotsToRestore.push(viewport); }); this.prevActiveViewport = this.activeViewport; this.prevViewportLayout = this.viewportLayout; let currCamera = this.activeViewport.camera; this.useLayout(VIEWPORT_LAYOUT.Single); this.activeViewport.camera = currCamera; this.isViewportMaximized = true; this.doOnResize(); } restoreViewport() { this.resetViewports(); while(this.viewprotsToRestore.length > 0) { let vp = this.viewprotsToRestore.pop(); vp.initCSSBorderDOM(); this.viewports.push(vp); } if (this.predefinedViewports.Single && this.prevCamInSingleLayout) { this.predefinedViewports.Single[0].camera = this.prevCamInSingleLayout; } this.viewportLayout = this.prevViewportLayout; this.activeViewport = this.prevActiveViewport; this.prevActiveViewport = null; this.prevCamInSingleLayout = null; this.prevViewportLayout = null; this.isViewportMaximized = false; this.doOnResize(); }

这里需要注意两个问题。一是我们使用预定义的单一视口来渲染最大化的视口,而如果预定义的单一视口原来已有其自己的相机,则须保存起来以备恢复。

第二个问题是在恢复时,我们不能简单地调用useLayout方法,那样会使用预定义的相机来覆盖所有视口的相机。因此我们须手工恢复并同步更新DOM树。

参考资源

  1. WebGL 2.0 Specification
  2. 详解投影变换