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

WebGLApp

撰写时间:2023-08-27

修订时间:2026-04-28

应用框架的主干

类图

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

WebGLApp Class Diagram

下面分析其代码。

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

import { WebGLUtils } from './WebGLUtils-v11.js'; import { GLColors, 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.BLACK });

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

而在WebGLApp的构造方法中:

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

如果参数opts为空值,则背景色取默认的黑色。创建完实例后,将私有静态变量#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类起到了的核心分派管理作用。

客户端代码

使用WebGLApp,客户端代码将变得非常精干。

这里集中列出上一章中 WebGLApp.html的客户端代码如下:

实例化一个app,选择使用一个视口布局,在场景中搭建3个实体模型及1个网格地板,运行app。就这么简单。

运行应用

从客户端看应用框架设计理念

客户端总共才32行代码,一个WebGL应用程序就得以运行。为何能有这种如此精练的效果?

一是使用了JavaScript Module。应用框架实际上在后台设计了为数不少的各个类。而这些类完全组件化,在需要时才导入,各个类的耦合度极低。例如ConeFrustumMesh, GridFloorMesh等类。

二是使用了回调机制。在appdoInInitViewportsdoInInitMeshes方法中,均自动注入了客户端所需的各个对象。客户端只需使用这些对象进行相应简单的配置即可。

三是各个类通过接口化设计,各个对象相互之间进行无缝协作。

例如,viewportManageruseLayout, sceneadd方法均是这些类向客户端暴露的公开接口方法,客户端依赖且可直接调用它们。除此之外,各个类还有其他的接口方法,客户端可能并不关心,但其它类在相互协作时则非常关心。

例如,当用户在应用程序的界面中扫动Magic Mouse的面板时,则Controls类最先监听到此事件消息,它将通知另外两个对象进行相应的处理:

这里,viewportCamerarotateviewportManagerdoOnCameraViewChanged方法均为接口方法,Controls依赖于这些接口方法。如果没有这些接口方法,程序流程将立即断开。

因此,尽管客户端代码中出现了cameraManagerviewportManagerscene等对象,但它们是如何被调用以生成最终的渲染效果的,客户端并不知晓。这些细节对客户端进行了隐藏。反正客户端也不关心这些。应用框架在后台将它们安排得明明白白的。从此点上看,我们的应用框架与WebGL渲染管道有异曲同工之妙。

四是完全基于事件消息驱动。

从上一节可以看出,WebGLApp的构造方法很简单,它仅仅是初始化了渲染环境:

而当客户端调用app.run时,在该方法内,先实例化一个Controls用于监听各种事件,然后再手工触发一个resize事件:

这样,Controls实例将收到resize事件被触发的消息,基于上面所谈到的应用构架的接口化设计,它立即委派相应的对象来响应事件。应用程序得以正确地运行起来。

因此,Controls如同《红楼梦》中荣国府的大管家王熙凤,一旦她接到史太夫人发来的指令,将立即指派手下人分工负责、各司其职、紧密协作地完成相应的工作。

五是充分运用设计模式,类与类之间的耦合度大为降低,使得类与类之间协调协作变得顺畅、规范,应用框架的内部结构更为紧密、合理。

各个类的设计中,先后运用到了单例模式工厂模式门面模式代理模式模板模式原型模式适配模式等模式。在后续章节实现这些类的代码中可看到它们的影子。

了解上述这些设计理念,下面再看到各个类的具体实现方式时就很清晰了。

应用框架的整体功能

视口

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

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

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

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

相机

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

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

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

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

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

必要的缓存机制

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

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

其他功能

Enter键切换进入全屏。

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

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

参考资源

  1. WebGL 2.0 Specification