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

Viewport

撰写时间:2023-09-13

修订时间:2023-09-13

Viewport

在本应用框架中,Viewport是非常重要的一个类,可以说是应用框架中心枢纽部分。能否科学、合理地设计此类,对整个应用框架的功能与效率有着重要的影响。

ViewportManager是所有Viewport的管理类,其doOnResize方法的代码如下:

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

委派各个Viewport类来具体处理resize事件。因此,我们先来看Viewport类的实现情况。

状态图

在有视口的环境下进行渲染之前,必须确保以下的状态已经准备好。

Viewport State Diagram

状态共分为两种,一种是当画布大小发生改变时的状态,二是当相机视图发生改变时的状态。其中,当画布大小发生改变时,应同时设置第一种状态及第二种状态。

首先,当画布的大小改变时,需更新Viewportrect。其结构为:

{ left: float, bottom: float, width: float, height: float }

正对应于视口的坐标系。

cssBorderDOM属性值的类型是一个HTMLDivElement,用于在界面上通过绘制其边框来标识各个视口的边框。此属性值应随画布大小的改变而改变。

projectionMatrix属性值的类型是一个表示矩阵的数据类型mat4,由于我们使用透视视图,透视矩阵的计算与视口的宽高比相关:

mat4.perspective(this.projectionMatrix, glMatrix.toRadian(this.camera.fov), this.rect.width / this.rect.height, 0.01, null);

因此,它也应随之重新计算。

现在,再来看第二种状态,即当相机取景发生改变时,应更新哪些状态。这种状态,实际上是每次渲染时应更新的状态。

当我们在视口进行平移、旋转、缩放等变换时,将由Camera类来记住这些状态,并反馈给Viewport类。因此,我们应根据这些矩阵,先更新着色器中的uProjectionMatrixuViewMatrix

之后,根据rect的值,调用glviewport方法,scissor方法及clear方法对本视口进行清屏。

这就是在渲染之前我们需要更新的状态。之后,将交由Scene类来读取模型的矩阵,并予以渲染。

类图

Viewport Class Diagram

源代码解析

构造方法

import { GLUHolder } from './WebGLUtils-v11.js'; import { Scene, Mesh, mat4, glMatrix, ViewportLayoutManager, } from './index-independent.js'; export class Viewport { viewportOpts; rect; projectionMatrix; cssBorderDOM; camera; scene = Scene.instance; viewportRenderMode = Mesh.RENDER_MODE.SOLID_WIREFRAME; constructor(camera, viewportOpts) { this.camera = camera; this.viewportOpts = viewportOpts; this.initCSSBorderDOM(); this.initProjectionMatrix(); } ... }

构造方法需要两个参数,一个是表示相机的camera。另一个是viewportOpts,用于动态获取视口大小。将这两个参数都存储为类的属性。

初始化

这里重现上面的状态图进行讲解。

Viewport State Diagram

初始化的作用是需要对左图中的cssBorderDOMprojectionMatrix这两个属性分配内存。

首先,为cssBorderDOM分配内存。

initCSSBorderDOM() { let div = document.createElement('div'); div.className = 'viewport-border'; GLUHolder.glu.canvas.parentNode.appendChild(div); this.cssBorderDOM = div; }

上面的代码,创建了一个HTMLDivElement,并将其添加为网页中id为canvas-containerdiv元素的子元素。对于4个视口的网页,在运行时将会形成下面的DOM树:

  • body
    • div id="canvas-container"
      • canvas
      • div class="viewport-border"
      • div class="viewport-border"
      • div class="viewport-border"
      • div class="viewport-border"

后面,将使用它们来动态标识出各个视口的边框线。最后,将此新生成的元素存储为类的cssBorderDOM属性值。

其次,为projectionMatrix分配内存:

initProjectionMatrix() { this.projectionMatrix = mat4.create(); }

这就完成了cssBorderDOMprojectionMatrix两个属性的内存分配。它们的值将在确定rect的属性值之后才随之确定。

响应事件

对应状态图,代码很容易编写了。

doOnResize() { this.updateViewportRect(); this.updateCSSBorderDOM(); this.updateProjectionMatrix(); this.doOnCameraViewChanged(); }

当画布大小改变时,依序更新状态图左图中的3个属性,然后再调用响应相机视图变换的事件处理代码。

首先更新rect属性值。

updateViewportRect() { GLUHolder.glu.syncCanvasSize(); const {layout, id} = this.viewportOpts; this.rect = ViewportLayoutManager.GetViewportRect(layout, id); }

先调用WebGLUtilssyncCanvasSize方法确定整个画布的大小:

syncCanvasSize() { let canvas = this.canvas; let canvasClientWidth = canvas.clientWidth * devicePixelRatio; let canvasClientHeight = canvas.clientHeight * devicePixelRatio; if (canvas.width !== canvasClientWidth || canvas.height !== canvasClientHeight) { canvas.width = canvasClientWidth; canvas.height = canvasClientHeight; } }

然后调用ViewportLayoutManagerGetViewportRect方法根据视口布局的类型及其该视口在此布局中的id来获取该视口的尺寸大小。因为布局较多,因此该代码较长,但均非常容易理解。例如,对于上下左右4个视口的布局,其代码如下:

if (layout === VIEWPORT_LAYOUT.Left_2_Right_2) { if (id === 1) { return { left: 0, bottom: gl.drawingBufferHeight / 2, width: gl.drawingBufferWidth / 2, height: gl.drawingBufferHeight / 2 }; } if (id === 2) { return { left: gl.drawingBufferWidth / 2, bottom: gl.drawingBufferHeight / 2, width: gl.drawingBufferWidth / 2, height: gl.drawingBufferHeight / 2 }; } if (id === 3) { return { left: 0, bottom: 0, width: gl.drawingBufferWidth / 2, height: gl.drawingBufferHeight / 2 }; } if (id === 4) { return { left: gl.drawingBufferWidth / 2, bottom: 0, width: gl.drawingBufferWidth / 2, height: gl.drawingBufferHeight / 2 }; } }

这样,各个视口的rect属性就存储了该视口的尺寸值。

然后据此更新cssBorderDOM的尺寸值。

updateCSSBorderDOM() { let div = this.cssBorderDOM; div.style.left = `${this.rect.left / devicePixelRatio}px`; div.style.bottom = `${this.rect.bottom / devicePixelRatio}px`; div.style.width = `${this.rect.width / devicePixelRatio}px`; div.style.height = `${this.rect.height / devicePixelRatio}px`; }

因为我们使用了显示器的高分辨率的特性,而CSS属性值无视于此,因此,需将rect的各个属性值均除以devicePixelRatio

再依据rect的属性值更新projectionMatrix的属性值:

updateProjectionMatrix() { mat4.perspective(this.projectionMatrix, glMatrix.toRadian(this.camera.fov), this.rect.width / this.rect.height, 0.01, null); }

这就完成了当画布尺寸改变时必须更新3个属性值的工作。下面该处理因相机视图变动而需做的工作了。

doOnCameraViewChanged() { this.applyTransforms(); this.clearViewport(); this.scene.renderMeshesInViewport(this.viewportRenderMode); }

第一步,将更新后的投影矩阵及相机的视图矩阵更新至着色器中的uProjectionMatrixuViewMatrix中。

applyTransforms() { const {gl, program} = GLUHolder.glu; gl.uniformMatrix4fv(program['uProjectionMatrix'], false, this.projectionMatrix); gl.uniformMatrix4fv(program['uViewMatrix'], false, this.camera.getViewMatrix()); }

当我们在当前视口平移、旋转、缩放场景时,Controls将以事件的参数调用camera的相应方法,最终更新cameraviewMatrix属性值。而我们只需调用其getViewMatrix方法即可获取该矩阵值。具体细节详见Camera一节。

第二步,清除当前视口区域。

clearViewport() { let gl = GLUHolder.glu.gl; gl.viewport(this.rect.left, this.rect.bottom, this.rect.width, this.rect.height); gl.scissor(this.rect.left, this.rect.bottom, this.rect.width, this.rect.height); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); }

我们之前已在WebGL的渲染管道中激活裁剪测试:

gl.enable(gl.DEPTH_TEST);

激活该功能后,glclear方法只清除viewport方法所确定的区域与scissor所确定的区域的重叠部分。上面的代码,这两部分的区域都使用了rect的值,因此只能清除本视口的区域,不会清除其他视口的内容。

至此,我们已处理投影矩阵、视图矩阵及其清屏。这些工作均与视口的区域大小有关,因此均集中由Viewport类处理。但我们尚未处理模型矩阵。

对于所有视口,不管该视口使用何种相机拍摄,都是拍摄同一群演员,只不过是拍摄角度不同而已。因此,模型矩阵与视口的职责无关,所有视口均共享同一模型矩阵。因此,在最后一步,我们将更新模型矩阵的任务交由Scene类来完成。

this.scene.renderMeshesInViewport(this.viewportRenderMode);

renderMeshesInViewport可以根据各个视口独有的viewportRenderMode属性值来进行渲染,这样在不同视口中可以独立地渲染为实体、框线、实体及框线等不同的渲染模式。详见Scene一节。

参考资源

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