WebGL Tutorial
and more

Viewport

撰写时间:2023-07-19

修订时间:2023-07-29

在本章中,我们将引入特定的辅助工具来帮助我们简化代码的编写。因涉及到JavaScript解包技术,因此第一节先快速地讲解该技术。然后,我们利用该技术来进行重构。在代码得到精简后,我们依序解决渲染图像模糊、高分辨率,以及图像变形的问题。最后,我们将进行第二次重构,且实现真正的全屏功能,通过将所要细节都隐藏起来,为下阶段的学习提供一个简明、干净的平台。

JavaScript解包

对象的解包

let person = { name: 'Mike', age: 25, sex: 'male' }; let {sex, name} = person; console.log(sex); // "male" console.log(name); // "Mike"

person是一个对象,包含name, agesex3个属性。

代码let {sex, name} = person;person进行解包,将其sexname两个属性分别赋值于sexname两个变量。其效果等效于下面代码:

let sex = person.sex; let name = person.name;

从上面代码可以看出,解包时,对象属性的顺序可以打乱。但,解包时,所自动创建的变量的名称应当与对象的原有属性的名称保持一致,否则,变量虽然还是会创建,但其值为undefined。因此,下面的代码是存在隐患的:

let {sex, hisName} = person; // Don't do this! console.log(sex); // "male" console.log(hisName); // undefined

如果我们解包时,需要将对象原有属性的名称更改为不同的变量名称,可依照下面的语法进行:

let {sex, name: hisName} = person; // extract 'name' to 'hisName' console.log(sex); // "male" console.log(hisName); // "Mike"

解包时,如果对象可能没有特定的属性名称,我们可以为自动创建的变量设置默认值。

let {age = 30, email = "sarkuya@163.com"} = person;

解包personageemail两个属性,并为它们设置了相应的默认值。这样,如果person没有相应的属性名称,则自动创建的变量被赋值为默认值;如果person已有相应的属性名称,则将该属性的值赋值于自动创建的变量。

console.log(age); // 25 console.log(email); // "sarkuya@163.com"

person已有age属性,因此自动创建的变量age的值来自于该属性的值;变量age的默认值30没派上用场。person没有email属性,因此自动创建的变量email的值取自默认值。

可见,在解包时为变量设置默认值这种防御性的风格增强了解包行为的安全性,尤其在需要在函数参数中进行解包时特别有用。详见下节。

函数参数的解包

我们可以在函数参数中使用解包的功能。

let person = { name: 'Mike', age: 25, sex: 'male' }; function test( {name, age} ) { console.log(name); // "Mike" console.log(age); // 25 } test(person);

test函数有一个形参,表面上看,该形参的类型为对象。但这是错觉。如果我们希望该函数的形参接收一个对象,应该这样编写代码:

function test( obj ) { console.log(obj.name); console.log(obj.age); }

因此,代码:

function test( {name, age} ) { ... }

实际上是准备对传入进来的实参进行解包。

现在,我们希望在test函数中处理email的信息,但由于所传进来的person对象没有email属性,根据上节的知识,我们可以为其设置一个默认值:

function test( {name, age, email = "sarkuya@163.com"} ) { console.log(name); // "Mike" console.log(age); // 25 console.log(email); // "sarkuya@163.com" }

同样,我们也无法确认用户传入的实参中是否一定含有nameage的属性,因此,对应这两个属性的自动创建的变量也最好设置默认值。

function test( { name = "sarkuya", age = 25, email = "sarkuya@163.com" }) { // function body ... }

这就是函数参数解包的格式及意义,在下节中,我们将使用这种格式。

重构:隐藏细节

在上一章的yellow-triangle.html文件中,所有的代码均集中于一个文件中,因此代码显得有些长。观察其代码,就会发现,像类似于创建并返回渲染环境、加载着色器、链接program等相关代码,均是每一个WebGL应用程序都应具备的重要部分,并且这些部分的代码不会有什么变化,因此我们可以通过创建特定的类来集中实现这些功能,从而达到隐藏细节的效果。

将相对稳定、相对固定不变的内容隐藏起来,将经常变换的内容留在客户端,允许用户输入不同的数值以应对各种复杂的、不同的场合,这是代码重构的一个重要原则。代码重构可以让我们摆脱过多繁琐细节的束缚,从更宏观的全局上关注业务逻辑。

现在,我们将引入一个新的类,将通用、不变的代码隐藏至其中。

创建WebGLUtils类

新建一个名为WebGLUtils-v0.js的文件,编写代码如下:

class WebGLUtils { constructor({ canvasId = 'webgl-canvas', shaderIds = ['vShader', 'fShader'], vertexAttribs }){ this.canvas = null; this.gl = null; this.program = null; this.getContext(canvasId); this.initProgram(shaderIds, vertexAttribs); } getContext(canvasId) { ... } initProgram([vShaderId, fShaderId], vertexAttribs) { ... } createVBO(arrData, compSize, attribName) { ... } bindVboToAttrib(vbo) { ... } } export default WebGLUtils;

构造器使用了上一节所介绍的函数参数解包技术,并设置了相应的默认值。

该类有canvasgl,及program3个实例属性instance property),这些属性都是一个WebGL应用中不可或缺的对象。

在构造器中,通过调用该类自身的getContext方法来初始化渲染环境,通过调用initProgram方法来编译、链接、使用program。这两个方法都属于私有方法private method)。

除此之外,WebGLUtils类还有createVBObindVboToAttrib方法,这些方法属于实例的公共方法public method),对外提供了API调用接口,允许客户端通过这些接口访问及管理类的实例属性,以实现特定的应用功能。

getContext方法实现如下:

getContext(canvasId) { if (!window.WebGLRenderingContext) { alert("Error! Window does not support WebGLRenderingContext!"); } let canvas = document.getElementById(canvasId); if (!canvas) { alert(`Error! Can not get canvas for ${canvasId}!`); } let context = canvas.getContext("webgl"); if (!context) { alert("Error! Can not get context for webgl!"); } this.canvas = canvas; this.gl = context; }

作为一个隐藏细节、实现常用功能的类,检测错误的代码必不可少。在取得canvas的实例后,将其保存进WebGLUtils的实例属性canvas;同样,在创建WebGLRenderingContext的实例后,将其保存进WebGLUtils的实例属性gl

initProgram方法负责加载着色器、编译并链接program.

initProgram([vShaderId, fShaderId], vertexAttribs) { let gl = this.gl; const vertexShader = loadShader(vShaderId); const fragmentShader = loadShader(fShaderId); let program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); let linked = gl.getProgramParameter(program, gl.LINK_STATUS); if (!linked) { let error = gl.getProgramInfoLog(program); alert(`Error in program linking: ${error}`); gl.deleteProgram(program); gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); return; } gl.useProgram(program); this.program = program; vertexAttribs.forEach((attribName) => { let loc = this.gl.getAttribLocation(this.program, attribName); this.program[attribName] = loc; }); function loadShader(shaderId) { let shaderScript = document.getElementById(shaderId); if (!shaderScript) { alert(`Error! Shader script ${shaderId} not found!`); return null; } let shaderType; if (shaderScript.type === "x-shader/x-vertex") { shaderType = gl.VERTEX_SHADER; } else if (shaderScript.type === "x-shader/x-fragment") { shaderType = gl.FRAGMENT_SHADER; } else { alert(`Error! Unkown shader type '${shaderScript.type}' for shader: '${shaderId}'`); return null; } let shader = gl.createShader(shaderType); gl.shaderSource(shader, shaderScript.text.trim()); gl.compileShader(shader); let compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS); if (!compiled) { let error = gl.getShaderInfoLog(shader); alert(`Error compiling shader ${shaderId}: ${error}`); gl.deleteShader(shader); return null; } return shader; } }

initProgram函数中的第一个形参,[vShaderId, fShaderId],使用了JavaScript对数组的解包技术,这样,在函数体中就可以直接使用解包后的变量了。

这段代码看起来较长,一是因为有较多的容错代码,二是因为loadShader方法也作为其内部函数予以实现。当链接program成功后,代码gl.useProgram(program)使用该program,代码this.program = program由类的实例属性program存储该对象。

注意这一段代码:

vertexAttribs.forEach((attribName) => { let loc = this.gl.getAttribLocation(this.program, attribName); this.program[attribName] = loc; });

将顶点属性在实例属性program中的位置保存为该属性的attribName属性,这样可大大方便客户端的调用。

createVBO方法用于创建并返回一个VBO对象。

createVBO(arrData, compSize, attribName) { let gl = this.gl; let vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arrData), gl.STATIC_DRAW); vbo.compSize = compSize; vbo.attribName = attribName; return vbo; }

参数compSize指定参数arrData中需要用多少个元素来表示每组坐标值,参数attribName指定该vbo所要绑定的顶点属性名称。并且,我们将其存储为所返回的vbocompSizeattribName属性,以方便glvertexAttribPointer方法调用。

bindVboToAttrib方法用以绑定vbo与顶点属性的连接。在实现中用到了上面所提到的vbocompSize属性及attribName属性。我们可以这样理解:由参数vbo负责记忆其自身的compSizeattribName,当需要时,找vbo要就行了。这里的设计理念是,通过完善各种对象的应有内部职责,减少程序的硬编码,从而使得应用程序更加灵活、更具普遍通用性。

bindVboToAttrib(vbo) { let gl = this.gl; let program = this.program; gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.vertexAttribPointer(program[vbo.attribName], vbo.compSize, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(program[vbo.attribName]); }

对于glprogram两个局域变量,我们也可通过前面学到的解包方法这样编写:

const {gl, program} = this;

以上是WebGLUtils-v0.js全部内容。有此支撑,客户端的代码就容易多了。

使用WebGLUtils类

新建一个名为app-use-webglutils.html的文件,其导入并使用WebGLUtils-v0.js的代码如下:

<script type="module"> import WebGLUtils from './js/esm/WebGLUtils-v0.js'; let glu; let gl; let vbo; init(); function init() { initContext(); initVBOs(); render(); } function initContext() { glu = new WebGLUtils({ vertexAttribs: ['aPosition'] }); gl = glu.gl; gl.clearColor(0, 0, 0, 1); } function initVBOs() { let vertices = [ 0.0, 0.5, -0.5, -0.5, 0.5, -0.5 ]; vbo = glu.createVBO(vertices, 2, 'aPosition'); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.bindVboToAttrib(vbo); gl.drawArrays(gl.TRIANGLES, 0, 3); } </script>

initContext函数中,代码:

glu = new WebGLUtils({ vertexAttribs: ['aPosition'] });

创建并返回一个WebGLUtils类的实例至glu变量。在构造函数的参数中,因为此应用程序只使用了aPosition一个顶点属性,因此我们只需将vertexAttribs属性值指定为['aPosition']。其他属性值,如canvasId以及shaderIds,依据WebGLUtils构造方法的声明:

class WebGLUtils { constructor({ canvasId = 'webgl-canvas', shaderIds = ['vShader', 'fShader'], vertexAttribs }){ ... } ... }

取默认值即可。

initVBOs函数中,代码:

vbo = glu.createVBO(vertices, 2, 'aPosition');

表示使用vertices的数据来创建一个VBO,在该数组中,用2个元素来表示每个顶点的一组坐标值,该VBO将绑定至aPosition顶点属性。

render函数中,要渲染一个VBO,只需这样调用:

glu.bindVboToAttrib(vbo); gl.drawArrays(gl.TRIANGLES, 0, 3);

可见,辅助类WebGLUtilscreateVBO方法及bindVboToAttrib方法帮我们隐藏了大量的技术细节,客户端的代码立即简洁了许多。

运行应用。渲染效果与yellow-triangle.html完全一致。

重构后的便利

重构后,如果需要渲染另外的图形,则比较简单。只需在initVBOs函数中调用glucreateVBO方法创建新的VBO的实例,并在render函数中再次调用glubindVboToAttrib方法及gldrawArrays方法即可:

新建一个名为two-vbos.html的文件,其JavaScript部分代码如下:

<script type="module"> import WebGLUtils from './js/esm/WebGLUtils-v0.js'; let glu; let gl; let vbo1, vbo2; ... function initVBOs() { vbo1 = glu.createVBO([ 0.0, 0.5, -0.5, -0.5, 0.5, -0.5 ], 2, 'aPosition'); vbo2 = glu.createVBO([ 0.5, 0.8, 0.5, 0.5, 0.8, 0.5, 0.8, 0.8 ], 2, 'aPosition'); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.bindVboToAttrib(vbo1); gl.drawArrays(gl.TRIANGLES, 0, 3); glu.bindVboToAttrib(vbo2); gl.drawArrays(gl.LINE_LOOP, 0, 4); } </script>

代码gl.drawArrays(gl.LINE_LOOP, 0, 4)使用4个顶点来绘制一个闭合的四边形。

运行应用。除了原来的三角形外,右上角还渲染了一个四边形,而代码依旧保持清晰、简洁。

可见,经重构后,业务逻辑十分清晰,用户的关注点从原来繁琐的技术细节立即转移到业务逻辑上面,且代码的可扩展性大大提升,代码的维护与升级立即变得更加简单、更加容易实现了。

代码重构在较大、较复杂的应用程序开发过程中发挥了重大作用,是必不可少的一项重要技术手段。因此,在本教程中,我会先以最原始的方式调用并讲解WebGL的各类API,以让读者先零距离地、原汁原味地体会WebGL原始API。然后通过不断的代码重构,将已经学习、掌握的技术细节隐藏起来,使得我们的关注点始终保持在最新学习的内容上面,从而达到最佳的学习效果。

此外,重构还有另外一个特别大的好处,即当我们逐渐学习、掌握并运用各类WebGL API后,我们将不知不觉、自然而然地开发出一个灵活、易用、维护方便的WebGL应用框架。

渲染图像为何模糊

上面的应用程序依旧没有解决渲染图像模糊不清的问题。

NDC到canvas的投影

我们知道,WebGL帧缓冲区frame buffer)中进行渲染。帧缓冲区采用的坐标系,其名称为Normalized device coordinates (标准化设备坐标系NDC)。NDC是笛卡尔坐标系,其原点在世界中心,x轴的值朝右增大,y轴的值朝上增大。

我们在上面的代码中就是采用了NDC来声明一个三角形的坐标值:

let vertices = [ 0.0, 0.5, -0.5, -0.5, 0.5, -0.5 ];

HTMLcanvas标签采用的是另外一种坐标系,其原点在于canvas的左上角,x轴的值朝右增大,y轴的值朝下增大。

由于我们要在canvas中显示帧缓冲区的渲染内容,因此,需要有一种机制,先将帧缓冲区每个像素的坐标值从NDC坐标系转换为canvas的坐标系后,再在canvas中按经转换过后的坐标值来绘制每个像素。这个过程,我们称之为将帧缓冲区投影到canvas中。

因为这两种坐标系统的原点不一样,且y轴的朝向相反,因此需通过数学中的线性转换来进行计算。

但作为程序员,我们可以无需理会其中的数学公式细节。相反,我们可想象为正如投影仪将图像或拉伸、或缩小地投影到特定尺寸的幕布上。

而在投影过程中,对于源与目标的范围也有不同的需求。我们可以只投影帧缓冲区的一部分内容,也可以投影帧缓冲区的全部内容;可以投影到canvas的整个区域,也可以只投影到canvas的一部分区域。这样,我们需要在帧缓冲区中定义一个四边形来框选源区域范围,在canvas中定义另外一个四边形来框选目标区域范围。

我们先来看如何在帧缓冲区定义这个四边形的情况。由于必须允许用户对所渲染的内容进行平移、缩放、旋转以及镜头推拉等情况,因此,该四边形的定义比较复杂,我们将在下面专列一章进行详细讲解。对于我们目前的程序而言,我们只希望按默认情况选定帧缓冲区中的所有区域范围。而在默认情况下,NDC落在视野范围内的x轴区域为[-1.0, 1.0],y轴区域也为[-1.0, 1.0]。因此,上述vertices的3个点的坐标均落在此范围内。这样,作为源的四至范围就确定下来了。

再来看如何在canvas定义这个四边形的情况。很幸运,WebGL提供了viewport方法,其4个参数定义了一个四边形,此四边形即在canvas中的投影目标区域范围。目前,我们需将源内容投影到canvas的整个区域,而这也是在绝大多数场合下的需求。

viewport方法

帧缓冲区大小的特性

当我们最初调用canvasgetContext方法来创建并返回一个WebGLRenderingContext的实例时,帧缓冲区的大小就确定下来了。

let gl = canvas.getContext('webgl'); console.dir(gl);

可以看到,gl共有4个属性,分别为canvas, drawingBufferColorSpace, drawingBufferWidth, 以及drawingBufferHeight。最后两个属性是只读属性,分别代表帧缓冲区的宽度与高度。

现在我们来看帧缓冲区大小与canvas大小的关系。

开始创建渲染环境时(假设不应用FullScreenCanvas.css的情况下):

console.log(canvas.width); // 300 console.log(canvas.height); // 150 console.log(gl.drawingBufferWidth); // 300 console.log(gl.drawingBufferHeight); // 150

默认情况下, canvas的宽度值为300px,高度值为150pxdrawingBufferWidthdrawingBufferHeight属性值与它们相应一致。

现在,人为改变canvas的大小:

canvas.setAttribute("width", 400); canvas.setAttribute("height", 450); console.log(canvas.width); // 400 console.log(canvas.height); // 450 console.log(gl.drawingBufferWidth); // 400 console.log(gl.drawingBufferHeight); // 450

两组属性值还是相应一致。可见,尽管drawingBufferWidth以及drawingBufferHeight均是只读属性,但drawingBufferWidth的值总是随着canvaswidth属性值变化而变化,drawingBufferHeight的值总是随着canvasheight属性值变化而变化。

viewport的默认设置

当渲染环境被创建时,WebGL会在其内部自动调用viewport方法:

let gl = canvas.getContext('webgl'); gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); // default setting

对于viewport的参数所定义的长方形,参数xy所确定的像素的坐标值为(0, 0),映射到canvas的左下角。且其宽度与高度分别为drawingBufferWidthdrawingBufferHeight的属性值。而根据上一节的结论,其宽度与高度就是canvas的宽度与高度。因此,帧缓冲区的内容将铺满整个canvas

two-vbos.html中这行代码屏蔽掉:

<!-- <link rel="stylesheet" href="/css/FullScreenCanvas.css" /> -->

暂不应用全屏的CSS样式,再运行该文件,你会发现,canvas的大小变成了默认的300px * 150px,而渲染出来的三角形却很清晰,根本没有模糊的边角。

也即说,在默认的情况下,帧缓冲区的大小与canvas的大小完全一致时,所渲染出来的图像就很清晰。

实际上,图像模糊的原因只有一个,即将较小尺寸的帧缓冲区的内容,投影到较大尺寸的canvas上面时,就会出现图像拉伸的情况,就会导致图像模糊。

恢复应用全屏CSS样式。

width及clientWidth属性的区别

在我们应用了FullScreenCanvas.css样式之后,canvas已经铺满浏览器的整个客户端,它的宽度与高度应该已经变化,根据上面的结论,gldrawingBufferWidthdrawingBufferHeight属性应该也随之而变化,为何还出现图像模糊?

原因是,在应用CSS样式后,canvas的尺寸是变大了,但其widthheight属性的数值未发生改变,改变的是clientWidth属性及clientHeight属性值。

在浏览器的检查器中输入:

console.log(canvas.width); // 300 console.log(canvas.height); // 150 console.log(canvas.clientWidth); // 1279 console.log(canvas.clientHeight); // 947

开始时,帧缓冲区的大小与canvas的大小完全一致,都是300px * 150px。后面应用了CSS样式,改变了canvasclientWidth属性及clientHeight属性,canvas的尺寸变大了。但,canvaswidth属性及height属性却未改变,导致帧缓冲区的宽度及高度也未发生改变。这样,源区域面积小,投影到面积变大的目标区域时,将因为拉伸而导致图像模糊。而我们的原有代码却什么都没做,没有再次调用viewport方法来设定帧缓冲区的大小并重新渲染,canvas中的图像根本没有任何变化,其效果,就是将原来300px * 150px的图像强行拉伸至1279px * 947px的区域,从而导致图像模糊。

因此,在渲染之前,我们应根据canvas在浏览器客户端中的实际大小来设置viewport。

two-vbos.html文件复制为viewport-0.html,添加一个updateViewport函数,并在initContext函数的最后进行调用:

function initContext() { glu = new WebGLUtils({ vertexAttribs: ['aPosition'] }); gl = glu.gl; gl.clearColor(0, 0, 0, 1); updateViewport(); } function updateViewport() { let canvas = gl.canvas; canvas.setAttribute("width", canvas.clientWidth); canvas.setAttribute("height", canvas.clientHeight); gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); }

运行应用。图像变清晰了。

devicePixelRatio的问题

现在,渲染图像够高清了吗?不一定。

对于一些高分辨率的设备,如苹果的设备上(不管是移动终端还是台式机),均装备了Retina高分辨率的显示屏。它们能够在同等面积的区域内以2倍或3倍的高分辨率来显示图像。

我们可以查看屏幕是几倍的分辨率:

console.log(window.devicePixelRatio); // 1, 2 or 3

如果该值大于等于2,则恭喜你,你有了一个高清设备。

然而,高分辨率不会自行改变canvasclientWidthclientHeight属性值。因此,上面的代码根据这两个属性值来设定帧缓冲区的大小,实际上白白浪费了这些设备的高性能。

viewport-0.html文件复制为viewport-1.html,修改updateViewport的代码:

function updateViewport() { let canvas = gl.canvas; let canvasClientWidth = canvas.clientWidth * devicePixelRatio; let canvasClientHeight = canvas.clientHeight * devicePixelRatio; if (canvas.width !== canvasClientWidth || canvas.height !== canvasClientHeight) { canvas.width = canvasClientWidth; canvas.height = canvasClientHeight; } gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); }

先将canvasclientWidthclientHeight的值分别乘以devicePixelRatio属性值后,如果不等于canvaswidthheight属性值,则将后两者的值修改为相应的值,最后再重新调用viewport方法。

运行应用。虽然目前渲染一个三角形不需要如此高的分辨率,但有此底层支撑,随着渲染图像越复杂,区别就会更加明显。

图像变形的问题

如果我们拉动浏览器的边框,改变浏览器的大小,就会发现渲染图像随着浏览器尺寸的改变而变化,宽度与高度的比例不再保持原来的比例,造成图像变形。

帧缓冲区中使用的是NDC坐标系,其比例是正确的,但当我们通过viewport投影至canvas时,canvas的宽高比不一定总是1:1的比例,因此造成了图像的失真变形。

解决的方法是,取canvas宽度及高度的最小值为边长构建一个正方形,以此作为参数调用viewport方法。

viewport-1.html文件复制为viewport-2.html,修改相应代码如下:

function updateViewport() { let canvas = gl.canvas; let canvasClientWidth = canvas.clientWidth * devicePixelRatio; let canvasClientHeight = canvas.clientHeight * devicePixelRatio; if (canvas.width !== canvasClientWidth || canvas.height !== canvasClientHeight) { canvas.width = canvasClientWidth; canvas.height = canvasClientHeight; } let squareLen = Math.min(gl.drawingBufferWidth, gl.drawingBufferHeight); gl.viewport((gl.drawingBufferWidth - squareLen) / 2, (gl.drawingBufferHeight - squareLen) / 2, squareLen, squareLen); } function initVBOs() { vbo1 = glu.createVBO([ 0.0, 0.5, -0.5, -0.5, 0.5, -0.5 ], 2, 'aPosition'); vbo2 = glu.createVBO([ -0.99, 0.99, -0.99, -0.99, 0.99, -0.99, 0.99, 0.99 ], 2, 'aPosition'); }

vbo2的图像改为代表NDC坐标系中的边框。

运行应用。渲染图像中除三角形外,还出现了一个随canvas的尺寸而定的最大化的正方形,且上下左右均居中于canvas

但如果此时改变浏览器大小,发现图像仍会变形。程序刚开始时正确,但浏览器大小改变时图像失真,说明我们需要对resize事件做出响应:调用viewport并重新渲染。

viewport-2.html文件复制为viewport-3.html,添加resize事件响应代码:

function init() { initWebEvents(); initContext(); initVBOs(); render(); } function initWebEvents() { window.addEventListener('resize', () => { updateViewport(); render(); }); }

运行应用。拉动浏览器边框而改变其大小,则会发现三角形及正方形的大小及位置虽然也会随之而变,但不会变形。

我们注意到正方形的四周会有留白,那是我们为了保持图像比例而舍弃掉的空间,不会参与渲染。这是目前的权宜之计,以后使用了投影矩阵变化技术后,就不会有此问题了。

实现真正的全屏

现在,上面所有问题已经解决,我们可以再次将细节隐藏起来。

WebGLUtils-v0.js文件复制为WebGLUtils-v1.js,修改如下:

class WebGLUtils { constructor({ canvasId = 'webgl-canvas', shaderIds = ['vShader', 'fShader'], vertexAttribs, renderFunc }){ this.canvas = null; this.gl = null; this.program = null; this.renderFunc = renderFunc; this.initWebEvents(); this.getContext(canvasId); this.initProgram(shaderIds, vertexAttribs); } initWebEvents() { window.addEventListener('resize', () => { this.updateViewport(); this.renderFunc(); }); document.addEventListener("keydown", evt => { if (evt.code === 'Enter') { if (!document.fullscreenElement) { this.canvas.requestFullscreen(); } else { document.exitFullscreen(); } } }); } getContext(canvasId) { ... this.updateViewport(); } updateViewport() { let gl = this.gl; 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; } let squareLen = Math.min(gl.drawingBufferWidth, gl.drawingBufferHeight); gl.viewport((gl.drawingBufferWidth - squareLen) / 2, (gl.drawingBufferHeight - squareLen) / 2, squareLen, squareLen); } ... } export default WebGLUtils;

构造器中的参数多了一个renderFunc,其类型为函数指针,以在需要时调用它以重新渲染。

注意,这里解包时不能使用renderFunc = render来设置默认值,因为WebGLUtils-v1.js上下文中无法识别render这个变量,必须从客户端传入。

initWebEvents函数中,我们还加入了下面代码:

document.addEventListener("keydown", evt => { if (evt.code === 'Enter') { if (!document.fullscreenElement) { this.canvas.requestFullscreen(); } else { document.exitFullscreen(); } } });

我们原来使用CSS来实现的全屏是伪全屏,只能让canvas铺满浏览器的整个客户端,但浏览器还是不能自动进入全屏。而上面的代码,只要在浏览器客户端中按下Enter键,就能实现真正的全屏。再按下Enter键或ESC键,退出全屏。

viewport-3.html文件复制为viewport.html,修改其内容如下:

<script type="module"> import WebGLUtils from './js/esm/WebGLUtils-v1.js'; let glu; let gl; let vbo1, vbo2; init(); function init() { initContext(); initVBOs(); render(); } function initContext() { glu = new WebGLUtils({ vertexAttribs: ['aPosition'], renderFunc: render }); gl = glu.gl; gl.clearColor(0, 0, 0, 1); } function initVBOs() { vbo1 = glu.createVBO([ 0.0, 0.5, -0.5, -0.5, 0.5, -0.5 ], 2, 'aPosition'); vbo2 = glu.createVBO([ -0.99, 0.99, -0.99, -0.99, 0.99, -0.99, 0.99, 0.99 ], 2, 'aPosition'); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.bindVboToAttrib(vbo1); gl.drawArrays(gl.TRIANGLES, 0, 3); glu.bindVboToAttrib(vbo2); gl.drawArrays(gl.LINE_LOOP, 0, 4); } </script>

initContext函数中创建WebGLUtils的实例时,必须传入本模块中的render函数。

如果不想通过函数指针的方式,也可以通过自定义事件的方式来实现,但总体上还是这种方式简明、干净。

运行应用。随意改变浏览器大小,确认所渲染的图像中正方形永远不会变形。按下Enter键,观察全屏模式下的图像是否变形。

参考资源

  1. The Drawing Buffer
  2. The WebGL Viewport