Viewport
撰写时间:2023-07-19
修订时间:2026-04-24
在本章中,我们将引入特定的辅助工具来帮助我们简化代码的编写。因涉及到JavaScript解包技术,因此第一节先快速地讲解该技术。然后,我们利用该技术来进行重构。在代码得到精简后,我们依序解决渲染图像模糊、高分辨率,以及图像变形的问题。最后,我们将进行第二次重构,且实现真正的全屏功能,通过将所要细节都隐藏起来,为下阶段的学习提供一个简明、干净的平台。
JavaScript解包
对象的解包
person是一个对象,包含name, age及sex3个属性。
代码let {sex, name} = person;对person进行解包,将其sex及name两个属性分别赋值于sex与name两个变量。其效果等效于下面代码:
从上面代码可以看出,解包时,对象属性的顺序可以打乱。但,解包时,所自动创建的变量的名称应当与对象的原有属性的名称保持一致,否则,变量虽然还是会创建,但其值为undefined。因此,下面的代码是存在隐患的:
如果我们解包时,需要将对象原有属性的名称更改为不同的变量名称,可依照下面的语法进行:
解包时,如果对象可能没有特定的属性名称,我们可以为自动创建的变量设置默认值。
解包person的age及email两个属性,并为它们设置了相应的默认值。这样,如果person没有相应的属性名称,则自动创建的变量被赋值为默认值;如果person已有相应的属性名称,则将该属性的值赋值于自动创建的变量。
person已有age属性,因此自动创建的变量age的值来自于该属性的值;变量age的默认值30没派上用场。person没有email属性,因此自动创建的变量email的值取自默认值。
可见,在解包时为变量设置默认值这种防御性的风格增强了解包行为的安全性,尤其在需要在函数参数中进行解包时特别有用。详见下节。
函数参数的解包
我们可以在函数参数中使用解包的功能。
test函数有一个形参,表面上看,该形参的类型为对象。但这是错觉。如果我们希望该函数的形参接收一个对象,应该这样编写代码:
因此,代码:
实际上是准备对传入进来的实参进行解包。
现在,我们希望在test函数中处理email的信息,但由于所传进来的person对象没有email属性,根据上节的知识,我们可以为其设置一个默认值:
同样,我们也无法确认用户传入的实参中是否一定含有name或age的属性,因此,对应这两个属性的自动创建的变量也最好设置默认值。
这就是函数参数解包的格式及意义,在下节中,我们将使用这种格式。
重构:隐藏细节
在上一章的yellow-triangle.html文件中,所有的代码均集中于一个文件中,因此代码显得有些长。观察其代码,就会发现,像类似于创建并返回渲染环境、加载着色器、链接program等相关代码,均是每一个WebGL应用程序都应具备的重要部分,并且这些部分的代码不会有什么变化,因此我们可以通过创建特定的类来集中实现这些功能,从而达到隐藏细节的效果。
将相对稳定、相对固定不变的内容隐藏起来,将经常变换的内容留在客户端,允许用户输入不同的数值以应对各种复杂的、不同的场合,这是代码重构的一个重要原则。代码重构可以让我们摆脱过多繁琐细节的束缚,从更宏观的全局上关注业务逻辑。
现在,我们将引入一个新的类,将通用、不变的代码隐藏至其中。
创建WebGLUtils类
新建一个名为WebGLUtils-v0.js的文件,编写代码如下:
构造器使用了上一节所介绍的函数参数解包技术,并设置了相应的默认值。
该类有canvas,gl,及program等3个实例属性(instance properties),这些属性都是一个WebGL应用中不可或缺的对象。
在构造器中,通过调用该类自身的getContext方法来初始化渲染环境,通过调用initProgram方法来编译、链接、使用program。这两个方法都属于私有方法(private method)。
除此之外,WebGLUtils类还有createVBO及bindVboToAttrib方法,这些方法属于实例的公共方法(public methods),对外提供了API调用接口,允许客户端通过这些接口访问及管理类的实例属性,以实现特定的应用功能。
getContext方法实现如下:
作为一个隐藏细节、实现常用功能的类,检测错误的代码必不可少。在取得canvas的实例后,将其保存进WebGLUtils的实例属性canvas;同样,在创建WebGLRenderingContext的实例后,将其保存进WebGLUtils的实例属性gl。
initProgram方法负责加载着色器、编译并链接program.
initProgram函数中的第1个形参,[vShaderId, fShaderId],使用了JavaScript对数组的解包技术,这样,在函数体中就可以直接使用解包后的变量了。
这段代码看起来较长,一是因为有较多的容错代码,二是因为loadShader方法也作为其内部函数予以实现。当链接program成功后,代码gl.useProgram(program)使用该program,代码this.program = program由类的实例属性program存储该对象。
注意这一段代码:
将顶点属性在实例属性program中的位置保存为program的attribName属性,这样可大大方便客户端的调用。
createVBO方法用于创建并返回一个VBO对象。
参数compSize指定参数arrData中需要用多少个元素来表示每组坐标值,参数attribName指定该vbo所要绑定的顶点属性名称。并且,我们将其存储为所返回的vbo对象的compSize及attribName属性,以方便后续的gl的vertexAttribPointer方法调用。
bindVboToAttrib方法用以绑定vbo与顶点属性的连接。在实现中用到了上面所提到的vbo的compSize属性及attribName属性。我们可以这样理解:由参数vbo负责记忆其自身的compSize及attribName,当需要时,找vbo要就行了。这里的设计理念是,通过完善各种对象的应有内部职责,减少程序的硬编码,从而使得应用程序更加灵活、更具普遍通用性。
对于gl及program两个局域变量,我们也可通过前面学到的解包方法这样编写:
以上是WebGLUtils-v0.js全部内容。有此支撑,客户端的代码就容易多了。
使用WebGLUtils类
新建一个名为app-use-webglutils.html的文件,其导入并使用WebGLUtils-v0.js的代码如下:
在initContext函数中,代码:
创建并返回一个WebGLUtils类的实例至glu变量。在构造函数的参数中,因为此应用程序只使用了aPosition一个顶点属性,因此我们只需将vertexAttribs属性值指定为['aPosition']
。其他属性值,如canvasId以及shaderIds,依据WebGLUtils构造方法的声明:
取默认值即可。
在initVBOs函数中,代码:
表示使用vertices的数据来创建一个VBO,在该数组中,用2个元素来表示每个顶点的一组坐标值,该VBO将绑定至aPosition顶点属性。
在render函数中,要渲染一个VBO,只需这样调用:
可见,辅助类WebGLUtils的createVBO方法及bindVboToAttrib方法帮我们隐藏了大量的技术细节,客户端的代码立即简洁了许多。
运行应用。渲染效果与上一章中的yellow-triangle.html完全一致。
重构后的便利
重构后,如果需要渲染另外的图形,则比较简单。只需在initVBOs函数中调用glu的createVBO方法创建新的VBO的实例,并在render函数中再次调用glu的bindVboToAttrib方法及gl的drawArrays方法即可:
新建一个名为two-vbos.html的文件,其JavaScript部分代码如下:
代码gl.drawArrays(gl.LINE_LOOP, 0, 4)使用4个顶点来绘制一个闭合的四边形。
运行应用。除了原来的三角形外,右上角还渲染了一个四边形线框,而代码依旧保持清晰、简洁。
可见,经重构后,业务逻辑十分清晰,用户的关注点从原来繁琐的技术细节立即转移到业务逻辑上面,且代码的可扩展性大大提升,代码的维护与升级立即变得更加简单、更加容易实现了。
代码重构在较大、较复杂的应用程序开发过程中发挥了重大作用,是必不可少的一项重要技能。因此,在本教程中,我会先以最原始的方式调用并讲解WebGL的各类APIs,以让读者先零距离地、原汁原味地体会WebGL原始APIs。然后通过不断的代码重构,将已经学习、掌握的技术细节隐藏起来,使得我们的关注点始终保持在最新学习的内容上面,从而达到最佳的学习效果。
此外,重构还有另外一个特别大的好处,即当我们逐渐学习、掌握并运用各类WebGL APIs后,我们将不知不觉、自然而然地开发出一个灵活、易用、维护方便的WebGL应用框架。
渲染图像为何模糊
上面的应用程序依旧没有解决渲染图像模糊不清的问题。
NDC到canvas的投影
两种坐标系
我们知道,WebGL在帧缓冲区(frame buffer)中进行渲染。帧缓冲区采用的坐标系,其名称为Normalized device coordinates (标准化设备坐标系, NDC)。NDC是笛卡尔坐标系,其原点在世界中心,X轴正向朝右,值域为[-1.0, 1.0],Y轴正向朝上,值域为[-1.0, 1.0]。
我们在上面的代码中就是采用了NDC来声明一个三角形的坐标值:
而HTML的canvas标签采用的是另外一种坐标系,其原点在于canvas的左上角,X轴正向朝右,Y轴正向朝下。
由于我们要在canvas中显示帧缓冲区的渲染内容,因此,需要有一种机制,先将帧缓冲区每个像素的坐标值从NDC坐标系转换为canvas的坐标系后,再在canvas中按经转换过后的坐标值来绘制每个像素。这个过程,我们称之为将帧缓冲区投影到canvas中。或者也可称为,从NDC坐标系到canvas坐标系的变换。
坐标系变换机制
当在这两个坐标系中进行变换时,WebGL的机制比较独特。
gl的drawingBufferWidth及drawingBufferHeight属性分别存储了帧缓冲区的宽度值及高度值。这两个属性值是只读的,WebGL将按特定的算法予以动态确定其值(详见下面)。
现在,假设帧缓冲区的大小确定下来了,WebGL需要知道,我们要将整个帧缓冲区的内容映射到canvas的哪一部分区域中?
我们可通过代码:
告诉WebGL,需将整个帧缓冲区的内容都按比例投影至canvas的整个区域。
这一行代码就足够了,WebGL将帮我们自动进行坐标系转换。
可见,viewport用于指定canvas中的投影目标区域。
NDC坐标系的本质
帧缓冲区的大小是动态的,那我们如何在帧缓冲区中精准定位绘图?
在为各个模型指定其顶点位置时,我们无需了解帧缓冲区的确切大小。相反,WebGL说,给你一个NDC坐标系范围,你只需在此范围内绘图即可。此范围为:X轴的值域为[-1.0, 1.0],Y轴的值域为[-1.0, 1.0]。
这就是我们之前经常编写代码:
的内在原因。
可见,NDC坐标系是一种采用了百分比来定义的坐标系,WebGL最终须将各个坐标值以特定方式乘以gl的drawingBufferWidth或drawingBufferHeight值后,才能得出确切的坐标值。
现在,取上面vertices中的一顶点:
则xnd值为0.0,ynd值为0.5。
当调用代码:
后,则笛卡尔世界坐标系中的坐标xw及yw可通过以下公式得出:
`x_w = (x_(nd) + 1) ("width" / 2) + x`
`y_w = (y_(nd) + 1) ("height" / 2) + y`
则,
`x_w = (0.0 + 1) ("width" / 2) + 0 = "width" / 2`
`y_w = (0.5 + 1) ("height" / 2) + 0 = 1.5 \times ("height" / 2)`
上面的世界坐标系中的坐标最终将转换为canvas中的坐标。
故此,NDC也经常称为与设备无关的坐标系。
以上这些细节,就是为何WebGL需使用NDC坐标系、为何gl的drawingBufferWidth及drawingBufferHeight属性为只读属性、为何viewport方法须传入drawingBufferWidth及drawingBufferHeight值作为参数的一系列内在原因:我们在与一个由WebGL内部管理的帧缓冲区打交道;WebGL引擎将自动在内部进行相应的变换运算。
下面,我们将详细了解WebGL如何动态地确定帧缓冲区大小的机制。
viewport方法
帧缓冲区大小的特性
当我们最初调用canvas的getContext方法来创建并返回一个WebGLRenderingContext的实例时,帧缓冲区的大小就确定下来了。gl的drawingBufferWidth代表帧缓冲区的宽度,drawingBufferHeight代表帧缓冲区的高度。
drawingBufferWidth及drawingBufferHeight是只读属性,我们不能直接修改其值。
我们尝试将drawingBufferWidth及drawingBufferHeight属性值均改为1200,但因为它们均为只读属性,故修改无效,值依旧为300及150。
现在我们来看帧缓冲区大小与canvas大小的关系。
开始创建渲染环境时(假设不应用FullScreenCanvas.css的情况下):
默认情况下, canvas的width属性值为300px,height属性值为150px。gl的drawingBufferWidth及drawingBufferHeight属性值与它们相应一致。
现在,人为改变canvas的大小:
两组属性值还是相应一致。可见,尽管drawingBufferWidth以及drawingBufferHeight均是只读属性,但drawingBufferWidth的值总是随着canvas的width属性值变化而变化,drawingBufferHeight的值总是随着canvas的height属性值变化而变化。
viewport的默认设置
当渲染环境被创建时,WebGL会在其内部自动调用viewport方法:
对于viewport的参数所定义的长方形,参数x及y所确定的像素的坐标值为(0, 0),映射到canvas的左下角 (left bottom corner)。且其宽度与高度分别为drawingBufferWidth与drawingBufferHeight的属性值。而根据上一节的结论,其宽度与高度就是canvas的宽度与高度。因此,帧缓冲区的内容将铺满整个canvas。
将two-vbos.html中这行代码屏蔽掉:
暂不应用全屏的CSS样式,再运行该文件,你会发现,canvas的大小变成了默认的300px * 150px,而渲染出来的三角形却很清晰,根本没有模糊的边角。
也即说,在默认的情况下,帧缓冲区的大小与canvas的大小完全一致时,所渲染出来的图像就很清晰。
实际上,图像模糊的原因只有一个,即将较小尺寸的帧缓冲区的内容,投影到较大尺寸的canvas上面时,就会出现图像拉伸的情况,就会导致图像模糊。
恢复应用全屏CSS样式。
width及clientWidth属性的区别
在我们应用了FullScreenCanvas.css样式之后,canvas已经铺满浏览器的整个客户端,它的宽度与高度应该已经变化,根据上面的结论,gl的drawingBufferWidth及drawingBufferHeight属性应该也随之而变化,为何还出现图像模糊?
原因是,在应用CSS样式后,canvas的尺寸是变大了,但其width及height属性的数值未发生改变,改变的是clientWidth属性及clientHeight属性值。
开始时,帧缓冲区的大小与canvas的大小完全一致,都是300px * 150px。后面应用了CSS样式,改变了canvas的clientWidth属性及clientHeight属性,canvas的尺寸变大了。但,canvas的width属性及height属性却未改变,导致帧缓冲区的宽度及高度也未发生改变。这样,源区域面积小,投影到面积变大的目标区域时,将因为拉伸而导致图像模糊。而我们的原有代码却什么都没做,没有再次调用viewport方法来设定帧缓冲区的大小并重新渲染,canvas中的图像根本没有任何变化,其效果,就是将原来300px * 150px的图像强行拉伸至400px * 400px的区域,从而导致图像模糊。
因此,在渲染之前,我们应根据canvas在浏览器客户端中的实际大小来设置viewport。
将two-vbos.html文件复制为viewport-0.html,添加一个updateViewport函数,并在initContext函数的最后进行调用:
CSS的设置先改变canvas的clientWidth及clientHeight的值,然后,调用canvas的setAttribute方法,根据canvas的客户端尺寸设置canvas的width及height属性值,从而改变gl的drawingBufferWidth及drawingBufferHeight属性值。最后,调用gl的viewport方法,取出帧缓冲区的所有渲染内容。
运行应用。图像变清晰了。
devicePixelRatio的问题
现在,渲染图像够高清了吗?不一定。
对于一些高分辨率的设备,如苹果的设备上(不管是移动终端还是台式机),均装备了Retina高分辨率的显示屏。它们能够在同等面积的区域内以2倍或3倍的高分辨率来显示图像。
我们可以查看屏幕是几倍的分辨率:
如果该值大于等于2,则恭喜你,你有了一个高清设备。
然而,高分辨率不会自行改变canvas的clientWidth, clientHeight或width, height属性值。因此,上一节的代码在设置帧缓冲区的大小时,未考虑devicePixelRatio的情况,实际上白白浪费了这些设备的高性能。
将viewport-0.html文件复制为viewport-1.html,修改updateViewport的代码:
先将canvas的clientWidth及clientHeight的值分别乘以devicePixelRatio属性值后,如果不等于canvas的width及height属性值,则将后两者的值修改为相应的值,最后再重新调用viewport方法。
运行应用。虽然目前渲染一个三角形不需要如此高的分辨率,但有此底层支撑,随着渲染图像越复杂,区别就会更加明显。
图像变形的问题
如果我们拉动浏览器的边框,改变浏览器的大小,就会发现渲染图像随着浏览器尺寸的改变而变化,宽度与高度的比例不再保持原来的比例,造成图像变形。
帧缓冲区中使用的是NDC坐标系,其比例是正确的,但当我们通过viewport投影至canvas时,canvas的宽高比不一定总是1:1的比例,因此造成了图像的失真变形。
解决的方法是,取canvas宽度及高度的最小值为边长构建一个正方形,以此作为参数调用viewport方法。
将viewport-1.html文件复制为viewport-2.html,修改相应代码如下:
vbo2的图像改为代表NDC坐标系中的边框。
运行应用。渲染图像中除三角形外,还出现了一个随canvas的尺寸而定的最大化的正方形,且上下左右均居中于canvas。
但如果此时改变浏览器大小,发现图像仍会变形。程序刚开始时正确,但浏览器大小改变时图像失真,说明我们需要对resize事件做出响应:调用viewport并重新渲染。
将viewport-2.html文件复制为viewport-3.html,添加resize事件响应代码:
运行应用。拉动浏览器边框而改变其大小,则会发现三角形及正方形的大小及位置虽然也会随之而变,但不会变形。
我们注意到正方形的四周会有留白,那是我们为了保持图像比例而舍弃掉的空间,不会参与渲染。这是目前的权宜之计,以后使用了投影矩阵变化技术后,就不会有此问题了。
实现真正的全屏
现在,上面所有问题已经解决,我们可以再次将细节隐藏起来。
将WebGLUtils-v0.js文件复制为WebGLUtils-v1.js,修改如下:
构造器中的参数多了一个renderFunc,其类型为函数指针,以在需要时调用它以重新渲染。
注意,这里解包时不能使用renderFunc = render来设置默认值,因为WebGLUtils-v1.js上下文中无法识别render这个变量,必须从客户端传入。
在initWebEvents函数中,我们还加入了下面代码:
我们原来使用CSS来实现的全屏
是伪全屏,只能让canvas铺满浏览器的整个客户端,但浏览器还是不能自动进入全屏。而上面的代码,只要在浏览器客户端中按下Enter键,就能实现真正的全屏。再按下Enter键或ESC键,退出全屏。
将viewport-3.html文件复制为viewport.html,修改其内容如下:
在initContext函数中创建WebGLUtils的实例时,必须传入本模块中的render函数。
如果不想通过函数指针的方式,也可以通过自定义事件的方式来实现,但总体上还是这种方式简明、干净。
运行应用。随意改变浏览器大小,确认所渲染的图像中正方形永远不会变形。按下Enter键,观察全屏模式下的图像是否变形。
