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

WebGLApp

撰写时间:2023-08-27

修订时间:2023-09-13

应用框架的主干

类图

WebGLApp类是应用框架的主干程序。上节中我们已经晓得其主要职责。我们先从全局上看其类图:

WebGLApp Class Diagram

下面分析其代码。

客户端负责创建的单例模式

import { WebGLUtils } from './WebGLUtils-v11.js'; import { CameraManager, ViewportManager, Scene, Controls } from './index-independent.js'; export class WebGLApp { static #instance = null; static get instance() { if (!WebGLApp.#instance) { throw new Error('WebGLApp has not been initialized!'); } return WebGLApp.#instance; } constructor(opts) { const { bgColor = GLColors.BLACK } = opts ??= {}; this.initContext(bgColor); WebGLApp.#instance = this; } ... }

这一部分的代码是构造方法。

首先,使用了单例模式。即使是单例模式,也有不同的实现方法。之前我们使用的单例模式中,如果尚未创建实例则直接创建并返回实例,因此客户端可以无须使用new语句来获得实例。这种方式一般适用于无参数的构造方法。

但对于确实需要从客户端通过参数来传进数据的构造方法来讲,上述方式由于无法预测客户端可能需要什么样的配置,因而导致自行实例化的方式比较僵化。具体到类似于WebGLApp这样的类来讲,由于它是较多内容的载体,我们希望客户端明确地通过new语句来创建实例,以传入所有所需配置。因此,上一节中客户端的代码:

let app = new WebGLApp({ bgColor: GLColors.FromShortHex('#000') });

即可满足这样的要求。这里采用对象作为参数,可大大方便以后的扩展。

而在WebGLApp的构造方法中:

constructor(opts) { const { bgColor = GLColors.BLACK } = opts ??= {}; this.initContext(bgColor); WebGLApp.#instance = this; }

如果参数为空值,则背景色取默认的黑色。创建完实例后,将私有静态变量#instance设置为所创建的实例。这样,客户端在以后即可通过WebGLApp.instance来获取此实例。

而如果客户端未先通过调用new语句来创建实例时,代码:

static get instance() { if (!WebGLApp.#instance) { throw new Error('WebGLApp has not been initialized!'); } return WebGLApp.#instance; }

将拒绝自行创建实例并抛出异常。

初始化渲染环境

initContext方法负责初始化渲染环境。

initContext(bgColor) { let glu = new WebGLUtils({ uniforms: ['uProjectionMatrix', 'uViewMatrix', 'uModelMatrix'], vertexAttribs: ['aPosition', 'aColor'] }); const {gl} = glu; gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(1.0, 1.0); gl.enable(gl.DEPTH_TEST); gl.enable(gl.SCISSOR_TEST); gl.clearColor(bgColor[0], bgColor[1], bgColor[2], bgColor[3]); }

这一部分的代码有一半是之前我们经常使用的。

代码:

gl.enable(gl.POLYGON_OFFSET_FILL); gl.polygonOffset(1.0, 1.0);

在两个以上的多边形完全重叠时,通过设置一点偏移值来改善渲染效果。

代码:

gl.enable(gl.SCISSOR_TEST);

将激活裁剪测试功能。当激活此功能后,只有在裁剪区与视口重叠的区域中的内容才会被渲染或清除。因为我们要使用多个视口,激活此功能后,将有两个好处:一是提高程序运行效率,二是各个视口的内容在独立更新时不会相互影响。

为客户端代码注入对象

我们已经初始化了渲染环境,在应用运行之前,仍需要用户从客户端配置视口、相机位置,以及将从客户端所创建的各个网格物体添加进场景中。

如前所述,与其让用户在客户端随意地自行创建各个类的实例,不如先在这里创建这些实例,然后再注入到回调函数的参数中供用户使用。

doInInitViewports(callback) { callback(CameraManager.instance, ViewportManager.instance); } doInInitMeshes(callback) { callback(Scene.instance); }

这两个方法的参数都是函数。当客户端调用这两个方法时,WebGLApp则将以创建好的类的实例作为参数直接调用这些函数。这样,用户在客户端就可以直接使用这些实例了。

CameraManager, ViewportManagerScene这三个类都同时已被设计为单例模式,因此只需访问它们的instance属性值即可实现类的实例化。

运行应用框架

run方法让整个应用框架跑起来。其代码为:

run() { if (ViewportManager.instance.viewports.length === 0) { throw new Error('No viewports!'); } new Controls(); window.dispatchEvent(new Event('resize')); }

代码:

if (ViewportManager.instance.viewports.length === 0) { throw new Error('No viewports!'); }

是为了预防程序被意外地设置为不正确的状态而编写的防御性代码。在这里,只要已有一个以上的视口,则视为正常状态;否则,直接抛出异常。

实际上,由于用户在上面一节中已在客户端调用了:

viewportManager.useLayout(VIEWPORT_LAYOUT.Left_2_Right_2);

已经排除了意外出现的可能。而在一个应用框架的架构设计中,因各个类的数量很多,比较容易出错,因此在适当的位置插入一条防御性代码可大大提升应用程序的稳健性。当然,这需要我们在稳健及效率两者之间取得一个较好的平衡。

Controls类专用于处理诸如resize以及鼠标操作、键盘按键等各类众多事件。在接收到这些事件后,由其负责向相应的对象分派事件处理任务。

我们不需要持有Controls类的实例变量,只要它被实例化了,它就会在后台自动处理各类事件。因此,只需调用:

new Controls();

即可。

在需要处理resize事件的程序中,总会存在一个小问题,即需要处理两个入口。第一个入口是应用程序开始运行时的初始状态,此时resize事件尚未被触发。第二个入口是应用程序运行后,当resize事件被触发时,此时,应用程序应在初始状态的前提下调整状态。因此,从为避免代码重复的角度出发,好的设计应将初始状态与resize事件发生时的状态分开,且设置完初始状态后,可以安全地人工触发resize事件。

由于我们在上面已经完成了初始化状态的设置,因此,在代码的最后,我们将人工触发一个resize事件:

window.dispatchEvent(new Event('resize'));

此时,Controls类实例将监听到该事件,并委派ViewportManager来处理该事件:

window.addEventListener('resize', (evt) => { this.updateViewports(); }); updateViewports() { this.viewportManager.doOnResize(); }

关系类图

WebGLApp类与其他类的关系图如下:

WebGLApp Relations

由上可见,WebGLApp类只是简单地实例化相应的类,并未持有它们的引用。

时序图

此时,WebGLApp类与其他类的交互时序图如下:

WebGLApp Sequence

从图中可清晰地看到,根据需要,WebGLApp依序创建了CameraManager, ViewportManager, Scene, 以及Controls的实例。

最后,当用户调用apprun方法时,将触发resize事件,Controls类实例监听到后,最终委派viewportManagerdoOnResize予以处理。因此,我们的应用框架本质上是以事件为驱动的框架结构,Controls类起到了的核心分派管理作用。

应用框架运行效果

运行当前进度的应用

视口

根据监听到的事件,Controls类委派ViewportManager类来统一处理视口相关任务。

根据用户的配置,屏幕划分为4个视口。左上为用户视图,右上为前视图,左下为左视图,右下为顶视图。点击其中一个视口,则激活该视口,以白色方框加亮显示。当我们在激活的视口中进行视图变换操作时,其他视口的视图不受影响。框架共定义了诸如单视口、上1下3等共计12种不同的视口,以满足不同场合的需求。

快捷键1234分别切换视口布局为1个视口、2个视口、3个视口及4个视口。由于2个视口的布局共有2个(左右、上下),3个视口的布局共有4个(左2右1、左1右2、上2下1、上1下2),4个视口的布局共有5个(左2右2、左3右1、左1右3、上3下1、上1下3),因此如果重复按下234键,可在其不同的布局中切换。

快捷键M键可将当前激活的视口最大化至铺满整个画布,再将按M键,恢复至之前大小。

相机

可用鼠标或键盘对整个场景进行旋转、平移、变焦的视图变换。

在Mac系统中,使用Magic Mouse的面板进行旋转,也可按住鼠标左键来旋转。使用⇧Shift+鼠标左键上下拉动进行变焦,使用⌘Command+鼠标左键上下左右拉动以进行平移。

在Linux或Windows系统中,按住鼠标左键来旋转。使用⇧Shift+鼠标左键上下拉动进行变焦,使用⌃Control+鼠标左键上下左右拉动以进行平移。

Mac系统下使用⌘Command++⌘Command+-来缩放。当焦距变化得太多而导致物体太大或太小时,可按⌘Command+0以恢复最初视图的焦距。在Linux或Windows系统中,辅助键改用⌃Control键。

快捷键F将当前视口切换至前视图,B后视图,L左视图,R右视图,T顶视图,O底视图。

必要的缓存机制

默认情况下,共有37个视口、7种相机。而每个视口又拥有自己独立的相机,也即37个相机。当用户改变视口布局、切换相机时,将不可避免地产生许多新的对象。为节省内存,提高应用程序运行效率,相机管理及视口管理均加入了缓存机制。我们使用延迟(lazy)生成机制,即只有在用户请求时,才会决定是否生成新的对象并放进缓冲池中。如果缓冲池中原来已经生成了相应的对象,则直接从缓冲池中取出,而不再生成新的对象。

有了这种缓存机制,我们即可非常放心地随意按下切换视口及视图的快捷键,既大大方便了我们使用不同布局、从不同角度查看物体的形状,同时也不会产生多余的内存垃圾。

其他功能

Enter键切换进入全屏。

快捷键S, W, D用以切换显示物体的实体、框线、实体及框线的渲染模式。

快捷键H用以切换显示网格地板。

参考资源

  1. WebGL 2.0 Specification